Merge remote-tracking branch 'origin/next' into 10870-pr-previews-ignore

This commit is contained in:
Andras Bacsai 2026-07-08 15:06:16 +02:00
commit e325f93662
72 changed files with 3366 additions and 737 deletions

View file

@ -1134,6 +1134,12 @@ private function loadCreatableItems()
public function navigateToResource($type) 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 // Find the item by type - check regular items first, then services
$item = collect($this->creatableItems)->firstWhere('type', $type); $item = collect($this->creatableItems)->firstWhere('type', $type);

View 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');
}
}

View file

@ -27,6 +27,13 @@ public function getListeners()
public function loadScripts() 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(); $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
} }

View 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');
}
}

View file

@ -22,6 +22,8 @@ class CloudProviderTokenForm extends Component
public string $name = ''; public string $name = '';
public ?string $description = null;
public function mount() public function mount()
{ {
try { try {
@ -37,6 +39,7 @@ protected function rules(): array
'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'token' => 'required|string', 'token' => 'required|string',
'name' => 'required|string|max:255', '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.'); return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
} }
$description = trim($this->description ?? '');
$savedToken = CloudProviderToken::create([ $savedToken = CloudProviderToken::create([
'team_id' => currentTeam()->id, 'team_id' => currentTeam()->id,
'provider' => $this->provider, 'provider' => $this->provider,
'token' => $this->token, 'token' => $this->token,
'name' => $this->name, 'name' => $this->name,
'description' => $description === '' ? null : $description,
]); ]);
auditLog('ui.cloud_token.created', [ auditLog('ui.cloud_token.created', [
@ -108,7 +114,7 @@ public function addToken()
'provider' => $savedToken->provider, 'provider' => $savedToken->provider,
]); ]);
$this->reset(['token', 'name']); $this->reset(['token', 'name', 'description']);
// Dispatch event with token ID so parent components can react // Dispatch event with token ID so parent components can react
$this->dispatch('tokenAdded', tokenId: $savedToken->id); $this->dispatch('tokenAdded', tokenId: $savedToken->id);

View file

@ -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) public function updated($property)
{ {
if ($property === 'value') { 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() private function validatePrivateKey()
{ {
$validationResult = PrivateKey::validateAndExtractPublicKey($this->value); $validationResult = PrivateKey::validateAndExtractPublicKey($this->value);

View file

@ -10,6 +10,31 @@ class Index extends Component
{ {
use AuthorizesRequests; 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() public function render()
{ {
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get();

View file

@ -4,6 +4,7 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
@ -22,8 +23,12 @@ class Show extends Component
public bool $isGitRelated = false; public bool $isGitRelated = false;
public bool $isInUse = false;
public $public_key = 'Loading...'; 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 protected function rules(): array
{ {
return [ return [
@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void
} }
} }
public function mount() public function mount(?string $private_key_uuid = null)
{ {
try { 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 // Explicit authorization check - will throw 403 if not authorized
$this->authorize('view', $this->private_key); $this->authorize('view', $this->private_key);
$this->syncData(false); $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.'); abort(403, 'You do not have permission to view this private key.');
} catch (\Throwable) { } catch (\Throwable) {
abort(404); abort(404);
@ -102,7 +108,15 @@ public function delete()
{ {
try { try {
$this->authorize('delete', $this->private_key); $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(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirectRoute($this, 'security.private-key.index'); return redirectRoute($this, 'security.private-key.index');

View file

@ -11,12 +11,21 @@ class Create extends Component
{ {
public $private_keys = []; public $private_keys = [];
public ?string $selectedType = null;
public ?string $selectedTokenUuid = null;
public bool $limit_reached = false; public bool $limit_reached = false;
public bool $has_hetzner_tokens = 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(); $this->private_keys = PrivateKey::ownedByCurrentTeamCached();
if (! isCloud()) { if (! isCloud()) {
$this->limit_reached = false; $this->limit_reached = false;

View 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');
}
}

View file

@ -12,6 +12,7 @@
use App\Rules\ValidHostname; use App\Rules\ValidHostname;
use App\Services\DigitalOceanService; use App\Services\DigitalOceanService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -33,6 +34,8 @@ class ByDigitalOcean extends Component
public ?int $selected_token_id = null; public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
public array $regions = []; public array $regions = [];
public array $images = []; public array $images = [];
@ -55,6 +58,8 @@ class ByDigitalOcean extends Component
public bool $loading_data = false; public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $enable_ipv6 = true; public bool $enable_ipv6 = true;
public bool $monitoring = true; public bool $monitoring = true;
@ -74,11 +79,12 @@ class ByDigitalOcean extends Component
public bool $from_onboarding = false; public bool $from_onboarding = false;
public function mount() public function mount(?string $selectedTokenUuid = null)
{ {
try { try {
$this->authorize('viewAny', CloudProviderToken::class); $this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens(); $this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->loadSavedCloudInitScripts(); $this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name(); $this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
@ -86,6 +92,11 @@ public function mount()
if ($this->private_keys->count() > 0) { if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id; $this->private_key_id = $this->private_keys->first()->id;
} }
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); 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; $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 private function getDigitalOceanToken(): string
@ -197,27 +226,48 @@ public function nextStep()
]); ]);
try { try {
$digitalOceanToken = $this->getDigitalOceanToken(); if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if (! $digitalOceanToken) { if ($token) {
return $this->dispatch('error', 'Please select a valid DigitalOcean token.'); return $this->redirectRoute('server.create.token', [
'type' => 'digital-ocean',
'token_uuid' => $token->uuid,
], navigate: true);
}
} }
$this->loadDigitalOceanData($digitalOceanToken);
$this->current_step = 2; $this->current_step = 2;
$this->loading_data = true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); 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; $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->loading_data = true;
$this->provider_data_error = null;
try { try {
$digitalOceanService = new DigitalOceanService($token); $digitalOceanService = new DigitalOceanService($token);
@ -232,10 +282,22 @@ private function loadDigitalOceanData(string $token): void
$this->loading_data = false; $this->loading_data = false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->loading_data = false; $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 public function getAvailableSizesProperty(): array
{ {
if (! $this->selected_region) { if (! $this->selected_region) {

View file

@ -12,6 +12,7 @@
use App\Rules\ValidHostname; use App\Rules\ValidHostname;
use App\Services\HetznerService; use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -37,6 +38,8 @@ class ByHetzner extends Component
// Step 1: Token selection // Step 1: Token selection
public ?int $selected_token_id = null; public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
// Step 2: Server configuration // Step 2: Server configuration
public array $locations = []; public array $locations = [];
@ -68,6 +71,8 @@ class ByHetzner extends Component
public bool $loading_data = false; public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $enable_ipv4 = true; public bool $enable_ipv4 = true;
public bool $enable_ipv6 = true; public bool $enable_ipv6 = true;
@ -89,11 +94,12 @@ class ByHetzner extends Component
public bool $from_onboarding = false; public bool $from_onboarding = false;
public function mount() public function mount(?string $selectedTokenUuid = null)
{ {
try { try {
$this->authorize('viewAny', CloudProviderToken::class); $this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens(); $this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->loadSavedCloudInitScripts(); $this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name(); $this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
@ -101,6 +107,11 @@ public function mount()
if ($this->private_keys->count() > 0) { if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id; $this->private_key_id = $this->private_keys->first()->id;
} }
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); 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; $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 private function validateHetznerToken(string $token): bool
@ -244,17 +273,20 @@ public function nextStep()
]); ]);
try { try {
$hetznerToken = $this->getHetznerToken(); if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if (! $hetznerToken) { if ($token) {
return $this->dispatch('error', 'Please select a valid Hetzner token.'); return $this->redirectRoute('server.create.token', [
'type' => 'hetzner',
'token_uuid' => $token->uuid,
], navigate: true);
}
} }
// Load Hetzner data // Move to step 2; provider data is loaded after initial render via wire:init.
$this->loadHetznerData($hetznerToken);
// Move to step 2
$this->current_step = 2; $this->current_step = 2;
$this->loading_data = true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -262,12 +294,26 @@ public function nextStep()
public function previousStep() public function previousStep()
{ {
if ($this->selectedTokenUuid) {
return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true);
}
$this->current_step = 1; $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->loading_data = true;
$this->provider_data_error = null;
$this->selectedHetznerSshKeyIds = []; $this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = []; $this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = []; $this->selectedHetznerNetworkIds = [];
@ -311,10 +357,22 @@ private function loadHetznerData(string $token)
$this->loading_data = false; $this->loading_data = false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->loading_data = false; $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 private function getCpuVendorInfo(array $serverType): ?string
{ {
$name = strtolower($serverType['name'] ?? ''); $name = strtolower($serverType['name'] ?? '');

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Server\New; namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Rules\ValidServerIp; 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) public function setPrivateKey(string $private_key_id)
{ {
$this->private_key_id = $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() public function instantSave()
{ {
// $this->dispatch('success', 'Application settings updated!'); // $this->dispatch('success', 'Application settings updated!');

View file

@ -12,6 +12,7 @@
use App\Rules\ValidHostname; use App\Rules\ValidHostname;
use App\Services\VultrService; use App\Services\VultrService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -33,6 +34,8 @@ class ByVultr extends Component
public ?int $selected_token_id = null; public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
public array $regions = []; public array $regions = [];
public array $plans = []; public array $plans = [];
@ -55,6 +58,8 @@ class ByVultr extends Component
public bool $loading_data = false; public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $enable_ipv6 = true; public bool $enable_ipv6 = true;
public bool $disable_public_ipv4 = false; public bool $disable_public_ipv4 = false;
@ -72,10 +77,11 @@ class ByVultr extends Component
public bool $from_onboarding = false; public bool $from_onboarding = false;
public function mount(): void public function mount(?string $selectedTokenUuid = null): void
{ {
$this->authorize('viewAny', CloudProviderToken::class); $this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens(); $this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->loadSavedCloudInitScripts(); $this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name(); $this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
@ -83,6 +89,11 @@ public function mount(): void
if ($this->private_keys->count() > 0) { if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id; $this->private_key_id = $this->private_keys->first()->id;
} }
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
} }
public function getListeners(): array 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; $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 public function nextStep(): mixed
@ -177,14 +206,19 @@ public function nextStep(): mixed
]); ]);
try { try {
$vultrToken = $this->getVultrToken(); if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if (! $vultrToken) { if ($token) {
return $this->dispatch('error', 'Please select a valid Vultr token.'); return $this->redirectRoute('server.create.token', [
'type' => 'vultr',
'token_uuid' => $token->uuid,
], navigate: true);
}
} }
$this->loadVultrData($vultrToken);
$this->current_step = 2; $this->current_step = 2;
$this->loading_data = true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -192,9 +226,15 @@ public function nextStep(): mixed
return null; 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; $this->current_step = 1;
return null;
} }
public function updatedSelectedRegion(): void public function updatedSelectedRegion(): void
@ -251,6 +291,29 @@ public function getSelectedServerPriceProperty(): ?string
return '$'.number_format((float) $monthlyCost, 2); 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 private function getVultrToken(): string
{ {
if ($this->selected_token_id) { if ($this->selected_token_id) {
@ -262,9 +325,19 @@ private function getVultrToken(): string
return ''; 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->loading_data = true;
$this->provider_data_error = null;
try { try {
$vultrService = new VultrService($token); $vultrService = new VultrService($token);
@ -288,10 +361,22 @@ private function loadVultrData(string $token): void
$this->loading_data = false; $this->loading_data = false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->loading_data = false; $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 private function createVultrServer(string $token): array
{ {
$vultrService = new VultrService($token); $vultrService = new VultrService($token);

View file

@ -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() public function checkConnection()
{ {
try { try {

View file

@ -828,6 +828,7 @@ public function linkToHetzner()
$this->hetznerSearchError = null; $this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -958,6 +959,7 @@ public function linkToDigitalOcean()
$this->digitalOceanSearchError = null; $this->digitalOceanSearchError = null;
$this->dispatch('success', 'Server successfully linked to DigitalOcean!'); $this->dispatch('success', 'Server successfully linked to DigitalOcean!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -1082,6 +1084,7 @@ public function linkToVultr()
$this->vultrSearchError = null; $this->vultrSearchError = null;
$this->dispatch('success', 'Server successfully linked to Vultr!'); $this->dispatch('success', 'Server successfully linked to Vultr!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);

View file

@ -2,9 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; class CloudInitScript extends BaseModel
class CloudInitScript extends Model
{ {
protected $fillable = [ protected $fillable = [
'team_id', 'team_id',

View file

@ -13,6 +13,7 @@ class CloudProviderToken extends BaseModel
'provider', 'provider',
'token', 'token',
'name', 'name',
'description',
]; ];
protected $hidden = [ protected $hidden = [

View file

@ -291,7 +291,7 @@ protected function ensureStorageDirectoryExists()
public function getKeyLocation() 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) public function updatePrivateKey(array $data)

View file

@ -22,6 +22,7 @@ public function __construct(
public ?string $canGate = null, public ?string $canGate = null,
public mixed $canResource = null, public mixed $canResource = null,
public bool $autoDisable = true, public bool $autoDisable = true,
public ?string $tooltip = null,
) { ) {
// Handle authorization-based disabling // Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) { if ($this->canGate && $this->canResource && $this->autoDisable) {

View file

@ -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');
});
}
};

View file

@ -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');
});
}
};

View file

@ -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 * Base styles
*/ */

View file

@ -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"> xmlns="http://www.w3.org/2000/svg">
<title>DigitalOcean</title> <title>DigitalOcean</title>
<path <path

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 658 B

View file

@ -1,4 +1,4 @@
@if ($authDisabled) @if ($authDisabled || filled($tooltip))
<span class="relative inline-flex" <span class="relative inline-flex"
x-data="{ visible: false, _t: null }" x-data="{ visible: false, _t: null }"
@mouseenter="_t = setTimeout(() => { @mouseenter="_t = setTimeout(() => {
@ -39,9 +39,9 @@
@endif @endif
@endif @endif
</button> </button>
@if ($authDisabled) @if ($authDisabled || filled($tooltip))
<div x-ref="tip" x-show="visible" x-cloak class="auth-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> </div>
</span> </span>
@endif @endif

View file

@ -1,6 +1,47 @@
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false" <div x-data="{
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}> open: false,
<div class="info-helper"> 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) @isset($icon)
{{ $icon }} {{ $icon }}
@else @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> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
@endisset @endisset
</div> </div>
<div class="info-helper-popup" :class="{ 'block': open }"> <template x-teleport="body">
<div class="p-4"> <div x-ref="popup" x-show="open" x-cloak :style="style"
{!! $helper !!} 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>
</div> </template>
</div> </div>

View file

@ -6,6 +6,7 @@
'buttonFullWidth' => false, 'buttonFullWidth' => false,
'customButton' => null, 'customButton' => null,
'disabled' => false, 'disabled' => false,
'disabledTooltip' => null,
'authDisabled' => false, 'authDisabled' => false,
'dispatchAction' => false, 'dispatchAction' => false,
'submitAction' => 'delete', 'submitAction' => 'delete',
@ -156,11 +157,11 @@
@else @else
@if ($disabled) @if ($disabled)
@if ($buttonFullWidth) @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 }} {{ $buttonTitle }}
</x-forms.button> </x-forms.button>
@else @else
<x-forms.button isError disabled :authDisabled="$authDisabled" wire:target> <x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }} {{ $buttonTitle }}
</x-forms.button> </x-forms.button>
@endif @endif

View file

@ -8,6 +8,7 @@
'content' => null, 'content' => null,
'closeOutside' => true, 'closeOutside' => true,
'isFullWidth' => false, 'isFullWidth' => false,
'wireIgnore' => true,
]) ])
@php @php
@ -17,7 +18,7 @@
<div x-data="{ modalOpen: false }" <div x-data="{ modalOpen: false }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })" x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false" :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) @if ($content)
<div @click="modalOpen=true"> <div @click="modalOpen=true">
{{ $content }} {{ $content }}

View file

@ -85,19 +85,14 @@ class="flex items-center justify-center size-4 text-black dark:text-white rounde
<h3>Servers</h3> <h3>Servers</h3>
@can('create', App\Models\Server::class) @can('create', App\Models\Server::class)
@if ($servers->count() > 0 && $privateKeys->count() > 0) @if ($servers->count() > 0 && $privateKeys->count() > 0)
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false"> <a href="{{ route('server.create') }}" {{ wireNavigate() }}
<x-slot:content> 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">
<button <svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
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"> viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" <path stroke-linecap="round" stroke-linejoin="round"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> d="M12 4.5v15m7.5-7.5h-15" />
<path stroke-linecap="round" stroke-linejoin="round" </svg>
d="M12 4.5v15m7.5-7.5h-15" /> </a>
</svg>
</button>
</x-slot:content>
<livewire:server.create />
</x-modal-input>
@endif @endif
@endcan @endcan
</div> </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> <div class='font-bold dark:text-warning'>No servers found.</div>
@can('create', App\Models\Server::class) @can('create', App\Models\Server::class)
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false"> <a href="{{ route('server.create') }}" {{ wireNavigate() }}>
<livewire:server.create /> <x-forms.button>Add</x-forms.button>
</x-modal-input> your first server </a> your first server
or or
go to the <a class="underline dark:text-white" go to the <a class="underline dark:text-white"
href="{{ route('onboarding') }}" href="{{ route('onboarding') }}"

View file

@ -245,6 +245,10 @@
if (value) { if (value) {
// Close search modal first // Close search modal first
this.closeModal(); this.closeModal();
if (value === 'server') {
window.location.href = '/servers/new';
return;
}
// Open the specific resource modal after a short delay // Open the specific resource modal after a short delay
setTimeout(() => { setTimeout(() => {
this.$dispatch('open-create-modal-' + value); 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> </div>
@else @else
<div <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" <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"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" /> 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>
<template x-if="!item.logo"> <template x-if="!item.logo">
<div <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" <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"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" /> 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> </template>
</div> </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" <div x-data="{ modalOpen: false }" @open-create-modal-team.window="modalOpen = true"
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto"> @keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
<template x-teleport="body"> <template x-teleport="body">

View file

@ -8,28 +8,28 @@
@forelse ($private_keys as $key) @forelse ($private_keys as $key)
@if ($private_key_id == $key->id) @if ($private_key_id == $key->id)
<div class="gap-2 py-4 cursor-pointer group coolbox" <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="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">
{{ $key->name }} {{ $key->name }}
</div> </div>
<div class="box-description"> <div class="box-description">
{{ $key->description }}</div> {{ $key->description }}</div>
<span wire:target="loadRepositories" wire:loading.delay
class="loading loading-xs dark:text-warning loading-spinner"></span>
</div> </div>
</div> </div>
@else @else
<div class="gap-2 py-4 cursor-pointer group coolbox" <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="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">
{{ $key->name }} {{ $key->name }}
</div> </div>
<div class="box-description"> <div class="box-description">
{{ $key->description }}</div> {{ $key->description }}</div>
<span wire:target="loadRepositories" wire:loading.delay
class="loading loading-xs dark:text-warning loading-spinner"></span>
</div> </div>
</div> </div>
@endif @endif

View file

@ -25,6 +25,8 @@
<div class="flex"> <div class="flex">
<div class="w-full gap-2 py-4 group coolbox" <div class="w-full gap-2 py-4 group coolbox"
wire:click.prevent="loadRepositories({{ $ghapp->id }})" wire:click.prevent="loadRepositories({{ $ghapp->id }})"
wire:loading.class="coolbox-loading"
wire:target="loadRepositories({{ $ghapp->id }})"
wire:key="{{ $ghapp->id }}"> wire:key="{{ $ghapp->id }}">
<div class="flex mr-4"> <div class="flex mr-4">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
@ -36,9 +38,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col items-center justify-center">
<x-loading wire:loading wire:target="loadRepositories({{ $ghapp->id }})" />
</div>
</div> </div>
@endforeach @endforeach
</div> </div>

View file

@ -75,7 +75,9 @@ class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200
</div> </div>
</div> </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"> <div x-show="!loading" class="flex flex-col gap-4 py-4">
<h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2> <h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2>
<div x-show="filteredGitBasedApplications.length > 0 || filteredDockerBasedApplications.length > 0" <div x-show="filteredGitBasedApplications.length > 0 || filteredDockerBasedApplications.length > 0"

View file

@ -5,11 +5,6 @@
helper="Enter your cloud-init script. Supports cloud-config YAML format." required /> helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
<div class="flex justify-end gap-2"> <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> <x-forms.button type="submit" isHighlighted>
{{ $scriptId ? 'Update Script' : 'Create Script' }} {{ $scriptId ? 'Update Script' : 'Create Script' }}
</x-forms.button> </x-forms.button>

View file

@ -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>

View file

@ -1,48 +1,30 @@
<div> <div>
<x-security.navbar /> <x-security.navbar />
<div class="flex gap-2"> <div class="flex items-center gap-2">
<h2 class="pb-4">Cloud-Init Scripts</h2> <h2>Cloud-Init Scripts</h2>
@can('create', App\Models\CloudInitScript::class) @can('create', App\Models\CloudInitScript::class)
<x-modal-input buttonTitle="+ Add" title="New Cloud-Init Script"> <x-modal-input buttonTitle="+ Add" title="New Cloud-Init Script">
<livewire:security.cloud-init-script-form /> <livewire:security.cloud-init-script-form />
</x-modal-input> </x-modal-input>
@endcan @endcan
</div> </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"> <div class="grid gap-4 lg:grid-cols-2">
@forelse ($scripts as $script) @forelse ($scripts as $script)
<div wire:key="script-{{ $script->id }}" @can('view', $script)
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline"> <a wire:key="script-{{ $script->id }}" class="coolbox group"
<div class="flex justify-between items-center"> href="{{ route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]) }}" {{ wireNavigate() }}>
<div class="flex-1"> <div class="flex flex-col justify-center mx-6">
<div class="font-bold dark:text-white">{{ $script->name }}</div> <div class="box-title">
<div class="text-xs text-neutral-500 dark:text-neutral-400"> {{ $script->name }}
Created {{ $script->created_at->diffForHumans() }} </div>
<div class="box-description">
Cloud-init script
</div> </div>
</div> </div>
</div> </a>
@endcan
<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>
@empty @empty
<div class="text-neutral-500">No cloud-init scripts found. Create one to get started.</div> <div class="text-neutral-500">No cloud-init scripts found. Create one to get started.</div>
@endforelse @endforelse

View file

@ -15,6 +15,9 @@
<x-forms.input required id="name" label="Token Name" <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" /> 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" <x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your 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>. target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console</a>.
@if ($provider === 'hetzner') @if ($provider === 'hetzner')
Choose Project Security API Tokens. 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 @endif
@if ($provider === 'digitalocean') @if ($provider === 'digitalocean')
Generate New Token. Generate New Token.
@endif @endif
@if ($provider === 'vultr') @if ($provider === 'vultr')
Open Account API Access. 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 @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>
@else @else
{{-- Full page layout: horizontal, spacious --}} {{-- Full page layout: horizontal, spacious --}}
<div class="flex gap-2 items-end flex-wrap"> <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"> <div class="flex-1 min-w-64">
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" /> <x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" />
</div> </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>
<div class="flex-1 min-w-64"> <div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token" <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>. target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console</a>.
@if ($provider === 'hetzner') @if ($provider === 'hetzner')
Choose Project Security API Tokens. 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 @endif
@if ($provider === 'digitalocean') @if ($provider === 'digitalocean')
Generate New Token. Generate New Token.
@endif @endif
@if ($provider === 'vultr') @if ($provider === 'vultr')
Open Account API Access. 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 @endif
</div> </div>
</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 @endif
</form> </form>
</div> </div>

View file

@ -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>

View file

@ -1,44 +1,87 @@
<div> <div>
<h2>Cloud Provider Tokens</h2> <div class="flex items-center gap-2">
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, Vultr, etc.).</div> <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> <div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
@can('create', App\Models\CloudProviderToken::class) x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
<livewire:security.cloud-provider-token-form :modal_mode="false" /> class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
@endcan <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> <x-modal-input title="Add DigitalOcean Token">
<div class="grid gap-2 lg:grid-cols-1"> <x-slot:content>
@forelse ($tokens as $savedToken) <div class="dropdown-item" @click="dropdownOpen = false">
<div wire:key="token-{{ $savedToken->id }}" <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<div class="flex items-center gap-2"> d="M12 4.5v15m7.5-7.5h-15" />
<span class="px-2 py-1 text-xs font-bold rounded dark:bg-coolgray-300 dark:text-white"> </svg>
{{ strtoupper($savedToken->provider) }} DigitalOcean
</span> </div>
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span> </x-slot:content>
</div> <livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div> wire:key="cloud-provider-token-digitalocean" />
</x-modal-input>
<div class="flex gap-2 pt-2"> <x-modal-input title="Add Vultr Token">
@can('view', $savedToken) <x-slot:content>
<x-forms.button wire:click="validateToken({{ $savedToken->id }})" type="button"> <div class="dropdown-item" @click="dropdownOpen = false">
Validate <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</x-forms.button> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@endcan d="M12 4.5v15m7.5-7.5h-15" />
</svg>
@can('delete', $savedToken) Vultr
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete" </div>
submitAction="deleteToken({{ $savedToken->id }})" :actions="[ </x-slot:content>
'This cloud provider token will be permanently deleted.', <livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"
'Any servers using this token will need to be reconfigured.', wire:key="cloud-provider-token-vultr" />
]" </x-modal-input>
confirmationText="{{ $savedToken->name }}" </div>
confirmationLabel="Please confirm the deletion by entering the token name below" </div>
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
@endcan
</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 @empty
<div> <div>
<div>No cloud provider tokens found.</div> <div>No cloud provider tokens found.</div>

View file

@ -3,11 +3,6 @@
<div>Private Keys are used to connect to your servers without passwords.</div> <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 class="font-bold">You should not use passphrase protected keys.</div>
</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'> <form class="flex flex-col gap-2" wire:submit='createPrivateKey'>
<div class="flex gap-2"> <div class="flex gap-2">
<x-forms.input id="name" label="Name" required /> <x-forms.input id="name" label="Name" required />

View file

@ -1,11 +1,55 @@
<div> <div>
<x-security.navbar /> <x-security.navbar />
<div class="flex gap-2"> <div class="flex items-center gap-2 pb-4">
<h2 class="pb-4">Private Keys</h2> <h2>Private Keys</h2>
@can('create', App\Models\PrivateKey::class) @can('create', App\Models\PrivateKey::class)
<x-modal-input buttonTitle="+ Add" title="New Private Key"> <div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<livewire:security.private-key.create /> <x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
</x-modal-input> + 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 @endcan
@can('create', App\Models\PrivateKey::class) @can('create', App\Models\PrivateKey::class)
<x-modal-confirmation title="Confirm unused SSH Key Deletion?" buttonTitle="Delete unused SSH Keys" isErrorButton <x-modal-confirmation title="Confirm unused SSH Key Deletion?" buttonTitle="Delete unused SSH Keys" isErrorButton

View file

@ -5,15 +5,21 @@
<x-security.navbar /> <x-security.navbar />
<div x-data="{ showPrivateKey: false }"> <div x-data="{ showPrivateKey: false }">
<form class="flex flex-col" wire:submit='changePrivateKey'> <form class="flex flex-col" wire:submit='changePrivateKey'>
<div class="flex items-start gap-2"> <div class="flex items-center gap-2 pb-4">
<h2 class="pb-4">Private Key</h2> <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"> <x-forms.button canGate="update" :canResource="$private_key" type="submit">
Save Save
</x-forms.button> </x-forms.button>
@if (data_get($private_key, 'id') > 0) @if (data_get($private_key, 'id') > 0)
@can('delete', $private_key) @can('delete', $private_key)
<x-modal-confirmation title="Confirm Private Key Deletion?" isErrorButton buttonTitle="Delete" <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.', 'This private key will be permanently deleted.',
'All servers connected to this private key will stop working.', 'All servers connected to this private key will stop working.',
'Any git app using 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 class="pl-1">Public Key</div>
</div> </div>
<x-forms.input canGate="update" :canResource="$private_key" readonly id="public_key" /> <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="flex items-end gap-2 py-2 ">
<div class="pl-1">Private Key <span class='text-helper'>*</span></div> <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" <div class="text-xs underline cursor-pointer dark:text-white" x-cloak x-show="!showPrivateKey"
@ -46,11 +53,6 @@
Hide Hide
</div> </div>
</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"> <div x-cloak x-show="!showPrivateKey">
<x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false" type="password" rows="10" id="privateKeyValue" <x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false" type="password" rows="10" id="privateKeyValue"
required disabled /> required disabled />

View file

@ -15,8 +15,10 @@
</x-modal-input> </x-modal-input>
@endcan @endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted <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 Validate token
<x-loading-on-button wire:loading wire:target="validateToken" />
</x-forms.button> </x-forms.button>
</div> </div>
<div class="pb-4">Change your server's {{ $providerName }} token.</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"> 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="flex flex-col w-full">
<div class="box-title">{{ $token->name }}</div> <div class="box-title">{{ $token->name }}</div>
@if ($token->description)
<div class="box-description">{{ $token->description }}</div>
@endif
<div class="box-description"> <div class="box-description">
Created {{ $token->created_at->diffForHumans() }} Created {{ $token->created_at->diffForHumans() }}
</div> </div>

View 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>

View file

@ -1,78 +1,117 @@
<div class="w-full"> <div class="w-full">
<div class="flex flex-col gap-4"> @if (!$selectedType)
@can('viewAny', App\Models\CloudProviderToken::class) <div class="flex flex-col items-center justify-center gap-3 sm:gap-6 sm:min-h-[calc(100vh-12rem)]">
<div> <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">
<x-modal-input title="Connect a Hetzner Server"> @can('viewAny', App\Models\CloudProviderToken::class)
<x-slot:content> <a href="{{ route('server.create.type', ['type' => 'hetzner']) }}" aria-label="Choose Hetzner"
<div class="relative gap-2 cursor-pointer coolbox group"> 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"
<div class="flex items-center gap-4 mx-6"> {{ wireNavigate() }}>
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <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" /> <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> </svg>
<div class="flex flex-col justify-center flex-1"> <span
<div class="box-title">Connect a Hetzner Server</div> 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">
<div class="box-description"> Provider
Deploy servers directly from your Hetzner Cloud account </span>
</div> </div>
</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>
</div> </div>
</x-slot:content> </a>
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div> <a href="{{ route('server.create.type', ['type' => 'vultr']) }}" aria-label="Choose Vultr"
<x-modal-input title="Connect a Vultr Server"> 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"
<x-slot:content> {{ wireNavigate() }}>
<div class="relative gap-2 cursor-pointer coolbox group"> <div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
<div class="flex items-center gap-4 mx-6"> <div class="flex items-center justify-between">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" <svg class="size-9 sm:size-14" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" /> <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" /> <path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg> </svg>
<div class="flex flex-col justify-center flex-1"> <span
<div class="box-title">Connect a Vultr Server</div> 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">
<div class="box-description"> Provider
Deploy servers directly from your Vultr account </span>
</div> </div>
</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>
</div> </div>
</x-slot:content> </a>
<livewire:server.new.by-vultr :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div> <a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
aria-label="Choose DigitalOcean"
<div> 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"
<x-modal-input title="Connect a DigitalOcean Droplet"> {{ wireNavigate() }}>
<x-slot:content> <div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
<div class="relative gap-2 cursor-pointer coolbox group"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4 mx-6"> <x-digital-ocean-icon class="size-9 sm:size-14" />
<x-digital-ocean-icon class="w-10 h-10 flex-shrink-0" /> <span
<div class="flex flex-col justify-center flex-1"> 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">
<div class="box-title">Connect a DigitalOcean Droplet</div> Provider
<div class="box-description"> </span>
Deploy servers directly from your DigitalOcean account </div>
</div> <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>
</div> </div>
</x-slot:content> </a>
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached" /> @endcan
</x-modal-input>
<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>
<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>
</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> </div>

View file

@ -5,9 +5,9 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h1>Servers</h1> <h1>Servers</h1>
@can('createAnyResource') @can('createAnyResource')
<x-modal-input buttonTitle="+ Add" title="New Server" :closeOutside="false"> <a href="{{ route('server.create') }}" {{ wireNavigate() }}>
<livewire:server.create /> <x-forms.button>+ Add</x-forms.button>
</x-modal-input> </a>
@endcan @endcan
</div> </div>
<div class="subtitle">All your servers are here.</div> <div class="subtitle">All your servers are here.</div>

View file

@ -4,42 +4,76 @@
@else @else
@if ($current_step === 1) @if ($current_step === 1)
<div class="flex flex-col w-full gap-4"> <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) @if ($available_tokens->count() > 0)
<div class="flex gap-2"> <div class="grid gap-3 md:grid-cols-2">
<div class="flex-1"> @foreach ($available_tokens as $token)
<x-forms.select label="Select DigitalOcean Token" id="selected_token_id" <a class="coolbox group text-left" wire:key="digital-ocean-token-{{ $token->id }}"
wire:change="selectToken($event.target.value)" required> href="{{ route('server.create.token', ['type' => 'digital-ocean', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
<option value="">Select a saved token...</option> <div class="flex flex-col justify-center mx-6">
@foreach ($available_tokens as $token) <div class="box-title">
<option value="{{ $token->id }}">
{{ $token->name ?? 'DigitalOcean Token' }} {{ $token->name ?? 'DigitalOcean Token' }}
</option> </div>
@endforeach <div class="box-description">
</x-forms.select> Use this token to create a DigitalOcean Droplet.
</div> </div>
<div class="flex items-end"> </div>
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep" </a>
:disabled="!$selected_token_id"> @endforeach
Continue </div>
</x-forms.button> @else
</div> <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>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif @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> </div>
@elseif ($current_step === 2) @elseif ($current_step === 2)
<div wire:init="loadDigitalOceanData">
@if ($loading_data) @if ($loading_data)
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
<div class="text-center"> <x-loading text="Loading DigitalOcean data..." />
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> </div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading DigitalOcean data...</p> @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>
</div> </div>
@else @else
@ -211,17 +245,13 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
</div> </div>
</x-dropdown> </x-dropdown>
<div class="flex gap-2 justify-between"> <x-forms.button class="w-full" isHighlighted canGate="create" :canResource="App\Models\Server::class"
<x-forms.button type="button" wire:click="previousStep"> type="submit" :disabled="!$private_key_id">
Back Buy & Create Server{{ $this->selectedDropletPrice ? ' (' . $this->selectedDropletPrice . '/mo)' : '' }}
</x-forms.button> </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>
</form> </form>
@endif @endif
</div>
@endif @endif
@endif @endif
</div> </div>

View file

@ -4,42 +4,76 @@
@else @else
@if ($current_step === 1) @if ($current_step === 1)
<div class="flex flex-col w-full gap-4"> <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) @if ($available_tokens->count() > 0)
<div class="flex gap-2"> <div class="grid gap-3 md:grid-cols-2">
<div class="flex-1"> @foreach ($available_tokens as $token)
<x-forms.select label="Select Hetzner Token" id="selected_token_id" <a class="coolbox group text-left" wire:key="hetzner-token-{{ $token->id }}"
wire:change="selectToken($event.target.value)" required> href="{{ route('server.create.token', ['type' => 'hetzner', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
<option value="">Select a saved token...</option> <div class="flex flex-col justify-center mx-6">
@foreach ($available_tokens as $token) <div class="box-title">
<option value="{{ $token->id }}">
{{ $token->name ?? 'Hetzner Token' }} {{ $token->name ?? 'Hetzner Token' }}
</option> </div>
@endforeach <div class="box-description">
</x-forms.select> Use this token to create a Hetzner server.
</div> </div>
<div class="flex items-end"> </div>
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep" </a>
:disabled="!$selected_token_id"> @endforeach
Continue </div>
</x-forms.button> @else
</div> <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>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif @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> </div>
@elseif ($current_step === 2) @elseif ($current_step === 2)
<div wire:init="loadHetznerData">
@if ($loading_data) @if ($loading_data)
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
<div class="text-center"> <x-loading text="Loading Hetzner data..." />
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> </div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading Hetzner data...</p> @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>
</div> </div>
@else @else
@ -248,17 +282,13 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
</div> </div>
</x-dropdown> </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" <x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class" type="submit"
:disabled="!$private_key_id"> :disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }} Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button> </x-forms.button>
</div>
</form> </form>
@endif @endif
</div>
@endif @endif
@endif @endif
</div> </div>

View file

@ -16,16 +16,65 @@
<div class="text-xs dark:text-warning text-coollabs ">Non-root user is experimental: <a <div class="text-xs dark:text-warning text-coollabs ">Non-root user is experimental: <a
class="font-bold underline" target="_blank" class="font-bold underline" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">docs</a>.</div> href="https://coolify.io/docs/knowledge-base/server/non-root-user">docs</a>.</div>
<x-forms.select label="Private Key" id="private_key_id"> <div class="flex items-end gap-2">
<option disabled>Select a private key</option> <div class="grow">
@foreach ($private_keys as $key) <x-forms.select label="Private Key" id="private_key_id">
@if ($loop->first) <option disabled>Select a private key</option>
<option selected value="{{ $key->id }}">{{ $key->name }}</option> @foreach ($private_keys as $key)
@else <option value="{{ $key->id }}">{{ $key->name }}</option>
<option value="{{ $key->id }}">{{ $key->name }}</option> @endforeach
@endif </x-forms.select>
@endforeach </div>
</x-forms.select> @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=""> <div class="">
<x-forms.checkbox instantSave type="checkbox" id="is_build_server" <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." helper="Build servers are used to build your applications, so you cannot deploy applications to it."

View file

@ -4,42 +4,76 @@
@else @else
@if ($current_step === 1) @if ($current_step === 1)
<div class="flex flex-col w-full gap-4"> <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) @if ($available_tokens->count() > 0)
<div class="flex gap-2"> <div class="grid gap-3 md:grid-cols-2">
<div class="flex-1"> @foreach ($available_tokens as $token)
<x-forms.select label="Select Vultr Token" id="selected_token_id" <a class="coolbox group text-left" wire:key="vultr-token-{{ $token->id }}"
wire:change="selectToken($event.target.value)" required> href="{{ route('server.create.token', ['type' => 'vultr', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
<option value="">Select a saved token...</option> <div class="flex flex-col justify-center mx-6">
@foreach ($available_tokens as $token) <div class="box-title">
<option value="{{ $token->id }}">
{{ $token->name ?? 'Vultr Token' }} {{ $token->name ?? 'Vultr Token' }}
</option> </div>
@endforeach <div class="box-description">
</x-forms.select> Use this token to create a Vultr server.
</div> </div>
<div class="flex items-end"> </div>
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep" </a>
:disabled="!$selected_token_id"> @endforeach
Continue </div>
</x-forms.button> @else
</div> <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>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif @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> </div>
@elseif ($current_step === 2) @elseif ($current_step === 2)
<div wire:init="loadVultrData">
@if ($loading_data) @if ($loading_data)
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
<div class="text-center"> <x-loading text="Loading Vultr data..." />
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> </div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading Vultr data...</p> @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>
</div> </div>
@else @else
@ -125,68 +159,82 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
@endif @endif
</div> </div>
<div> <x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
<x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds" <x-slot:title>
helper="Select existing SSH keys from your Vultr account to add to this server. The Coolify SSH key will be automatically added." <span class="text-sm font-medium">Advanced Vultr options</span>
:multiple="true" :disabled="count($vultrSshKeys) === 0" :placeholder="count($vultrSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Vultr account'"> <span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, network configuration, and cloud-init.</span>
@foreach ($vultrSshKeys as $sshKey) @if (count($this->advancedVultrOptionsSummary) > 0)
<option value="{{ $sshKey['id'] }}"> <span class="mt-1 flex flex-wrap gap-1.5">
{{ $sshKey['name'] }} - {{ substr($sshKey['id'], 0, 20) }} @foreach ($this->advancedVultrOptionsSummary as $summaryItem)
</option> <span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
@endforeach {{ $summaryItem }}
</x-forms.datalist> </span>
</div> @endforeach
</span>
<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>
@endif @endif
</div> </x-slot>
<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"> <div class="flex w-full flex-col gap-4 p-3">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" /> <div>
<div class="flex-1"> <x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds"
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." /> 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> </div>
</div> </x-dropdown>
<div class="flex gap-2 justify-between"> <x-forms.button class="w-full" isHighlighted canGate="create" :canResource="App\Models\Server::class"
<x-forms.button type="button" wire:click="previousStep"> type="submit" :disabled="!$private_key_id">
Back Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button> </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> </form>
@endif @endif
</div>
@endif @endif
@endif @endif
</div> </div>

View file

@ -9,9 +9,54 @@
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<h2>Private Key</h2> <h2>Private Key</h2>
@can('createAnyResource') @can('createAnyResource')
<x-modal-input buttonTitle="+ Add" title="New Private Key"> <div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<livewire:security.private-key.create /> <x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
</x-modal-input> + 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 @endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click.prevent='checkConnection'> <x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click.prevent='checkConnection'>
Check connection 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-title">{{ $private_key->name }}</div>
<div class="box-description">{{ $private_key->description }}</div> <div class="box-description">{{ $private_key->description }}</div>
</div> </div>
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid) <div class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'> <x-forms.button class="w-full"
Use this key @click.prevent="copyPublicKeyToClipboard({{ Js::from($private_key->public_key) }})">
Copy public key
</x-forms.button> </x-forms.button>
@else @if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
<x-forms.button class="w-full" disabled> <x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
Currently used Use this key
</x-forms.button> </x-forms.button>
@endif @else
<x-forms.button class="w-full" disabled>
Currently used
</x-forms.button>
@endif
</div>
</div> </div>
@empty @empty
<div>No private keys found. </div> <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> </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> </div>

View file

@ -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> <span>Validating...</span>
</div> </div>
@endif @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) @if ($server->id === 0)
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save" <x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
submitAction="submit" :actions="[ 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 @else
<x-forms.button type="submit" canGate="update" :canResource="$server" <x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button> :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()) @if ($server->isFunctional())
<x-slide-over closeWithX fullScreen> <x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title> <x-slot:title>Validate & configure</x-slot:title>
@ -482,228 +777,6 @@ class="dark:hover:fill-white fill-black dark:fill-warning">
@endif @endif
</div> </div>
@endif @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> </div>
</div> </div>

View file

@ -37,7 +37,9 @@
use App\Livewire\Project\Shared\Logs; use App\Livewire\Project\Shared\Logs;
use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Project\Show as ProjectShow;
use App\Livewire\Security\ApiTokens; use App\Livewire\Security\ApiTokens;
use App\Livewire\Security\CloudInitScript\Show as SecurityCloudInitScriptShow;
use App\Livewire\Security\CloudInitScripts; use App\Livewire\Security\CloudInitScripts;
use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenShow;
use App\Livewire\Security\CloudTokens; use App\Livewire\Security\CloudTokens;
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
@ -46,6 +48,7 @@
use App\Livewire\Server\Charts as ServerCharts; use App\Livewire\Server\Charts as ServerCharts;
use App\Livewire\Server\CloudflareTunnel; use App\Livewire\Server\CloudflareTunnel;
use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow; 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\Delete as DeleteServer;
use App\Livewire\Server\Destinations as ServerDestinations; use App\Livewire\Server\Destinations as ServerDestinations;
use App\Livewire\Server\DockerCleanup; use App\Livewire\Server\DockerCleanup;
@ -88,6 +91,7 @@
use App\Livewire\Team\Member\Index as TeamMemberIndex; use App\Livewire\Team\Member\Index as TeamMemberIndex;
use App\Livewire\Terminal\Index as TerminalIndex; use App\Livewire\Terminal\Index as TerminalIndex;
use App\Models\ScheduledDatabaseBackupExecution; use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -277,7 +281,9 @@
}); });
Route::get('/servers', ServerIndex::class)->name('server.index'); 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::prefix('server/{server_uuid}')->group(function () {
Route::get('/', ServerShow::class)->name('server.show'); 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/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', 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', 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'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens');
}); });

View file

@ -81,7 +81,7 @@ function createServerWithKeyForTeam(Team $team): void
createServerWithKeyForTeam($team); createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class) Livewire::test(Dashboard::class)
->assertSee('New Server'); ->assertSee(route('server.create'));
}); });
test('member does not see add server button on dashboard', function () { test('member does not see add server button on dashboard', function () {
@ -93,5 +93,5 @@ function createServerWithKeyForTeam(Team $team): void
createServerWithKeyForTeam($team); createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class) Livewire::test(Dashboard::class)
->assertDontSee('New Server'); ->assertDontSee(route('server.create'));
}); });

View file

@ -15,7 +15,11 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { 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(); $this->team = Team::factory()->create();

View file

@ -1,7 +1,10 @@
<?php <?php
use App\Http\Middleware\PreventRequestsDuringMaintenance; 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\Livewire\Server\Navbar as ServerNavbar;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
@ -132,6 +135,144 @@
expect(auth()->user()->can('create', Server::class))->toBeFalse(); 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 --- // --- Cross-team isolation ---
test('user from different team cannot view server', function () { test('user from different team cannot view server', function () {

View 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');
});

View 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.');
});

View 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);
});

View file

@ -82,6 +82,13 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
} }
describe('GitHub Private Repository Component', function () { 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 () { test('loadRepositories fetches and displays repositories', function () {
$repos = [ $repos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']], ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],

View 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');
});

View 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();
});

View file

@ -1,6 +1,8 @@
<?php <?php
use App\Livewire\Security\CloudProviderTokenForm; use App\Livewire\Security\CloudProviderTokenForm;
use App\Livewire\Security\CloudProviderTokens;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
@ -52,3 +54,36 @@
->and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded' ->and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded'
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue(); && 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.');
});

View 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);
});

View 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();
});

View file

@ -290,10 +290,9 @@
], 200), ], 200),
]); ]);
$component = Livewire::test(ByHetzner::class) $component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
->set('selected_token_id', $this->hetznerToken->id) ->assertSet('current_step', 2)
->call('nextStep') ->call('loadHetznerData');
->assertSet('current_step', 2);
expect($component->get('hetznerFirewalls'))->toHaveCount(1) expect($component->get('hetznerFirewalls'))->toHaveCount(1)
->and($component->get('hetznerNetworks'))->toHaveCount(1); ->and($component->get('hetznerNetworks'))->toHaveCount(1);

View 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'],
]);

View 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.');
});

View file

@ -75,6 +75,19 @@
expect($first->keys()->all())->toBe($second->keys()->all()); 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 () { it('renders the service templates last updated hint placeholder', function () {
View::share('errors', new ViewErrorBag); View::share('errors', new ViewErrorBag);

View file

@ -102,9 +102,7 @@ function vultrLivewireTestPublicKey(): string
], 202), ], 202),
]); ]);
Livewire::test(ByVultr::class) Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
->set('selected_token_id', $this->vultrToken->id)
->call('nextStep')
->assertSet('current_step', 2) ->assertSet('current_step', 2)
->set('server_name', 'test-vultr-server') ->set('server_name', 'test-vultr-server')
->set('selected_region', 'ewr') ->set('selected_region', 'ewr')
@ -148,3 +146,22 @@ function vultrLivewireTestPublicKey(): string
->call('submit') ->call('submit')
->assertHasErrors(['enable_ipv6']); ->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);
});

View file

@ -6,11 +6,61 @@
expect($view)->toContain('<x-forms.select required id="provider" label="Provider" wire:model.live="provider">') 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 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, 'Open Account → API Access.'))->toBe(2)
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->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('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>') ->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>')
->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr'"); ->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" />');
});

View file

@ -33,6 +33,19 @@
->toContain('redirectRoute($this, \'project.resource.create\''); ->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 () { it('ensures docker-image item has quickcommand with new image', function () {
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php'); $globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
@ -142,3 +155,13 @@
expect($globalSearchFile)->not->toContain("'logo_html' =>"); 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');
});