diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 4d9b1af35..bf64ee8e9 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1134,6 +1134,12 @@ private function loadCreatableItems() public function navigateToResource($type) { + if ($type === 'server') { + $this->dispatch('closeSearchModal'); + + return redirectRoute($this, 'server.create'); + } + // Find the item by type - check regular items first, then services $item = collect($this->creatableItems)->firstWhere('type', $type); diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php new file mode 100644 index 000000000..6e2a3937d --- /dev/null +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -0,0 +1,97 @@ + 'required|string|max:255', + 'script' => ['required', 'string', new ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function mount(string $cloud_init_script_uuid): void + { + try { + $this->cloudInitScript = CloudInitScript::ownedByCurrentTeam() + ->whereUuid($cloud_init_script_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudInitScript); + + $this->name = $this->cloudInitScript->name; + $this->script = $this->cloudInitScript->script; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud-init script.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudInitScript); + $this->validate(); + + $this->cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $this->cloudInitScript->id, + 'cloud_init_script_name' => $this->cloudInitScript->name, + ]); + + $this->dispatch('success', 'Cloud-init script updated successfully.'); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudInitScript); + + $scriptId = $this->cloudInitScript->id; + $scriptName = $this->cloudInitScript->name; + + $this->cloudInitScript->delete(); + + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + + return redirectRoute($this, 'security.cloud-init-scripts'); + } + + public function render() + { + return view('livewire.security.cloud-init-script.show'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 57b7324d3..e66a749cf 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -27,6 +27,13 @@ public function getListeners() public function loadScripts() { + CloudInitScript::ownedByCurrentTeam() + ->whereNull('uuid') + ->get() + ->each(function (CloudInitScript $script): void { + $script->forceFill(['uuid' => new_public_id()])->save(); + }); + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); } diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php new file mode 100644 index 000000000..aa9270be0 --- /dev/null +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -0,0 +1,177 @@ + 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Token name is required.', + ]; + } + + public function mount(string $cloud_token_uuid): void + { + try { + $this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam() + ->whereUuid($cloud_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudProviderToken); + + $this->name = $this->cloudProviderToken->name; + $this->description = $this->cloudProviderToken->description; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud token.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudProviderToken); + $this->validate(); + + $description = trim($this->description ?? ''); + + $this->cloudProviderToken->update([ + 'name' => $this->name, + 'description' => $description === '' ? null : $description, + ]); + + auditLog('ui.cloud_token.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->dispatch('success', 'Cloud provider token updated.'); + } + + public function validateToken(): void + { + $this->authorize('view', $this->cloudProviderToken); + + $isValid = match ($this->cloudProviderToken->provider) { + 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), + 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), + 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + default => false, + }; + + $providerName = $this->providerName(); + + $this->dispatch( + $isValid ? 'success' : 'error', + $isValid + ? "{$providerName} token is valid." + : "{$providerName} token validation failed. Please check the token." + ); + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + 'valid' => $isValid, + ]); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudProviderToken); + + if ($this->cloudProviderToken->hasServers()) { + $serverCount = $this->cloudProviderToken->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return null; + } + + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->cloudProviderToken->delete(); + + return redirectRoute($this, 'security.cloud-tokens'); + } + + public function providerName(): string + { + return match ($this->cloudProviderToken->provider) { + 'digitalocean' => 'DigitalOcean', + 'vultr' => 'Vultr', + default => 'Hetzner', + }; + } + + private function validateHetznerToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.hetzner.cloud/v1/servers?per_page=1') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateDigitalOceanToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateVultrToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 0a7235447..c2466c622 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -22,6 +22,8 @@ class CloudProviderTokenForm extends Component public string $name = ''; + public ?string $description = null; + public function mount() { try { @@ -37,6 +39,7 @@ protected function rules(): array 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', ]; } @@ -94,11 +97,14 @@ public function addToken() return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); } + $description = trim($this->description ?? ''); + $savedToken = CloudProviderToken::create([ 'team_id' => currentTeam()->id, 'provider' => $this->provider, 'token' => $this->token, 'name' => $this->name, + 'description' => $description === '' ? null : $description, ]); auditLog('ui.cloud_token.created', [ @@ -108,7 +114,7 @@ public function addToken() 'provider' => $savedToken->provider, ]); - $this->reset(['token', 'name']); + $this->reset(['token', 'name', 'description']); // Dispatch event with token ID so parent components can react $this->dispatch('tokenAdded', tokenId: $savedToken->id); diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 8b7ba73dd..8be1011d5 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -43,22 +43,6 @@ protected function messages(): array ); } - public function generateNewRSAKey() - { - $this->generateNewKey('rsa'); - } - - public function generateNewEDKey() - { - $this->generateNewKey('ed25519'); - } - - private function generateNewKey($type) - { - $keyData = PrivateKey::generateNewKeyPair($type); - $this->setKeyData($keyData); - } - public function updated($property) { if ($property === 'value') { @@ -93,14 +77,6 @@ public function createPrivateKey() } } - private function setKeyData(array $keyData) - { - $this->name = $keyData['name']; - $this->description = $keyData['description']; - $this->value = $keyData['private_key']; - $this->publicKey = $keyData['public_key']; - } - private function validatePrivateKey() { $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 0362b65fa..540ef5fa1 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,6 +10,31 @@ class Index extends Component { use AuthorizesRequests; + public function generatePrivateKey(string $type) + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index fa7397d13..826289b88 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -4,6 +4,7 @@ use App\Models\PrivateKey; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -22,8 +23,12 @@ class Show extends Component public bool $isGitRelated = false; + public bool $isInUse = false; + public $public_key = 'Loading...'; + public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.'; + protected function rules(): array { return [ @@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void } } - public function mount() + public function mount(?string $private_key_uuid = null) { try { - $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail(); // Explicit authorization check - will throw 403 if not authorized $this->authorize('view', $this->private_key); $this->syncData(false); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + $this->isInUse = $this->private_key->isInUse(); + } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { abort(404); @@ -102,7 +108,15 @@ public function delete() { try { $this->authorize('delete', $this->private_key); - $this->private_key->safeDelete(); + + if ($this->private_key->isInUse()) { + $this->isInUse = true; + $this->dispatch('error', $this->deleteDisabledReason); + + return; + } + + $this->private_key->delete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); return redirectRoute($this, 'security.private-key.index'); diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 5fd2ea4f7..7edfcaf72 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -11,12 +11,21 @@ class Create extends Component { public $private_keys = []; + public ?string $selectedType = null; + + public ?string $selectedTokenUuid = null; + public bool $limit_reached = false; public bool $has_hetzner_tokens = false; - public function mount() + public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + ? $selectedType + : null; + $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); if (! isCloud()) { $this->limit_reached = false; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php new file mode 100644 index 000000000..158f28351 --- /dev/null +++ b/app/Livewire/Server/CreatePage.php @@ -0,0 +1,55 @@ +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'); + } +} diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 6abb6759f..7b0151851 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\DigitalOceanService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -33,6 +34,8 @@ class ByDigitalOcean extends Component public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + public array $regions = []; public array $images = []; @@ -55,6 +58,8 @@ class ByDigitalOcean extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv6 = true; public bool $monitoring = true; @@ -74,11 +79,12 @@ class ByDigitalOcean extends Component public bool $from_onboarding = false; - public function mount() + public function mount(?string $selectedTokenUuid = null) { try { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -86,6 +92,11 @@ public function mount() if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -174,9 +185,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId): void + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } private function getDigitalOceanToken(): string @@ -197,27 +226,48 @@ public function nextStep() ]); try { - $digitalOceanToken = $this->getDigitalOceanToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $digitalOceanToken) { - return $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'digital-ocean', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - $this->loadDigitalOceanData($digitalOceanToken); $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } } - public function previousStep(): void + public function previousStep(): mixed { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'digital-ocean'], navigate: true); + } + $this->current_step = 1; + + return null; } - private function loadDigitalOceanData(string $token): void + public function loadDigitalOceanData(): void { + $token = $this->getDigitalOceanToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + + return; + } + $this->loading_data = true; + $this->provider_data_error = null; try { $digitalOceanService = new DigitalOceanService($token); @@ -232,10 +282,22 @@ private function loadDigitalOceanData(string $token): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('DigitalOcean', $e, 'message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + public function getAvailableSizesProperty(): array { if (! $this->selected_region) { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index e1191d79b..5ef88acee 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Livewire\Attributes\Locked; @@ -37,6 +38,8 @@ class ByHetzner extends Component // Step 1: Token selection public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + // Step 2: Server configuration public array $locations = []; @@ -68,6 +71,8 @@ class ByHetzner extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv4 = true; public bool $enable_ipv6 = true; @@ -89,11 +94,12 @@ class ByHetzner extends Component public bool $from_onboarding = false; - public function mount() + public function mount(?string $selectedTokenUuid = null) { try { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -101,6 +107,11 @@ public function mount() if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -207,9 +218,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId) + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } private function validateHetznerToken(string $token): bool @@ -244,17 +273,20 @@ public function nextStep() ]); try { - $hetznerToken = $this->getHetznerToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $hetznerToken) { - return $this->dispatch('error', 'Please select a valid Hetzner token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hetzner', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - // Load Hetzner data - $this->loadHetznerData($hetznerToken); - - // Move to step 2 + // Move to step 2; provider data is loaded after initial render via wire:init. $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } @@ -262,12 +294,26 @@ public function nextStep() public function previousStep() { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true); + } + $this->current_step = 1; } - private function loadHetznerData(string $token) + public function loadHetznerData(): void { + $token = $this->getHetznerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hetzner token.'); + + return; + } + $this->loading_data = true; + $this->provider_data_error = null; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; $this->selectedHetznerNetworkIds = []; @@ -311,10 +357,22 @@ private function loadHetznerData(string $token) $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index f5ea2ae80..a85306f6b 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; use App\Rules\ValidServerIp; @@ -84,11 +85,51 @@ protected function messages(): array ]); } + public function getListeners(): array + { + return [ + 'privateKeyCreated' => 'handlePrivateKeyCreated', + ]; + } + public function setPrivateKey(string $private_key_id) { $this->private_key_id = $private_key_id; } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->handlePrivateKeyCreated($privateKey->id); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + public function instantSave() { // $this->dispatch('success', 'Application settings updated!'); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index bc0b936d1..9e6bc3cf2 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\VultrService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -33,6 +34,8 @@ class ByVultr extends Component public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + public array $regions = []; public array $plans = []; @@ -55,6 +58,8 @@ class ByVultr extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv6 = true; public bool $disable_public_ipv4 = false; @@ -72,10 +77,11 @@ class ByVultr extends Component public bool $from_onboarding = false; - public function mount(): void + public function mount(?string $selectedTokenUuid = null): void { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -83,6 +89,11 @@ public function mount(): void if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } public function getListeners(): array @@ -165,9 +176,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId): void + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } public function nextStep(): mixed @@ -177,14 +206,19 @@ public function nextStep(): mixed ]); try { - $vultrToken = $this->getVultrToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $vultrToken) { - return $this->dispatch('error', 'Please select a valid Vultr token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'vultr', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - $this->loadVultrData($vultrToken); $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } @@ -192,9 +226,15 @@ public function nextStep(): mixed return null; } - public function previousStep(): void + public function previousStep(): mixed { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'vultr'], navigate: true); + } + $this->current_step = 1; + + return null; } public function updatedSelectedRegion(): void @@ -251,6 +291,29 @@ public function getSelectedServerPriceProperty(): ?string return '$'.number_format((float) $monthlyCost, 2); } + public function getAdvancedVultrOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedVultrSshKeyIds) > 0) { + $summary[] = count($this->selectedVultrSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedVultrSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv6 disabled'; + } + + if ($this->disable_public_ipv4) { + $summary[] = 'Public IPv4 disabled'; + } + + if (! empty($this->cloud_init_script)) { + $summary[] = 'cloud-init'; + } + + return $summary; + } + private function getVultrToken(): string { if ($this->selected_token_id) { @@ -262,9 +325,19 @@ private function getVultrToken(): string return ''; } - private function loadVultrData(string $token): void + public function loadVultrData(): void { + $token = $this->getVultrToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Vultr token.'); + + return; + } + $this->loading_data = true; + $this->provider_data_error = null; try { $vultrService = new VultrService($token); @@ -288,10 +361,22 @@ private function loadVultrData(string $token): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Vultr', $e, 'error'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function createVultrServer(string $token): array { $vultrService = new VultrService($token); diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 810b95ed4..54f3436dc 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -55,6 +55,33 @@ public function setPrivateKey($privateKeyId) } } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function checkConnection() { try { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 46f562a9e..15af859c9 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -828,6 +828,7 @@ public function linkToHetzner() $this->hetznerSearchError = null; $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); @@ -958,6 +959,7 @@ public function linkToDigitalOcean() $this->digitalOceanSearchError = null; $this->dispatch('success', 'Server successfully linked to DigitalOcean!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); @@ -1082,6 +1084,7 @@ public function linkToVultr() $this->vultrSearchError = null; $this->dispatch('success', 'Server successfully linked to Vultr!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 9d2676aca..671c5e76c 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudInitScript extends Model +class CloudInitScript extends BaseModel { protected $fillable = [ 'team_id', diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 27214f1d1..ab9897f9a 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -13,6 +13,7 @@ class CloudProviderToken extends BaseModel 'provider', 'token', 'name', + 'description', ]; protected $hidden = [ diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index d16213ad7..3f72642a5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -291,7 +291,7 @@ protected function ensureStorageDirectoryExists() public function getKeyLocation() { - return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); } public function updatePrivateKey(array $data) diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index 8511c87db..f03b36f0e 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -22,6 +22,7 @@ public function __construct( public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public ?string $tooltip = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php new file mode 100644 index 000000000..5d194cd7d --- /dev/null +++ b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php @@ -0,0 +1,28 @@ +text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php new file mode 100644 index 000000000..500fa1fcf --- /dev/null +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + DB::table('cloud_init_scripts') + ->whereNull('uuid') + ->orderBy('id') + ->each(function (object $script): void { + DB::table('cloud_init_scripts') + ->where('id', $script->id) + ->update(['uuid' => new_public_id()]); + }); + } + + public function down(): void + { + Schema::table('cloud_init_scripts', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index de92bf0c9..c354c6c53 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 */ diff --git a/resources/views/components/digital-ocean-icon.blade.php b/resources/views/components/digital-ocean-icon.blade.php index f3d15c6e2..5652730d6 100644 --- a/resources/views/components/digital-ocean-icon.blade.php +++ b/resources/views/components/digital-ocean-icon.blade.php @@ -1,4 +1,10 @@ -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 + + DigitalOcean - You do not have permission to perform this action. + {{ $tooltip ?: 'You do not have permission to perform this action.' }} @endif diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 2102e48b1..900beddd0 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,6 +1,47 @@ -
merge(['class' => 'group relative inline-block align-middle']) }}> -
+
merge(['class' => 'inline-block align-middle']) }}> +
@isset($icon) {{ $icon }} @else @@ -9,11 +50,13 @@ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"> @endisset -
-
-
- {!! $helper !!} +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 5efc9102b..830b7e09c 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -6,6 +6,7 @@ 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, + 'disabledTooltip' => null, 'authDisabled' => false, 'dispatchAction' => false, 'submitAction' => 'delete', @@ -156,11 +157,11 @@ @else @if ($disabled) @if ($buttonFullWidth) - + {{ $buttonTitle }} @else - + {{ $buttonTitle }} @endif diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 0f7347e5c..96bb3467c 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,6 +8,7 @@ 'content' => null, 'closeOutside' => true, 'isFullWidth' => false, + 'wireIgnore' => true, ]) @php @@ -17,7 +18,7 @@
+ class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif> @if ($content)
{{ $content }} diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 19d819613..1adac94a4 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -85,19 +85,14 @@ class="flex items-center justify-center size-4 text-black dark:text-white rounde

Servers

@can('create', App\Models\Server::class) @if ($servers->count() > 0 && $privateKeys->count() > 0) - - - - - - + + + + + @endif @endcan
@@ -154,9 +149,9 @@ class="flex items-center justify-center size-4 text-black dark:text-white rounde
No servers found.
@can('create', App\Models\Server::class) @else
+ class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center"> @@ -821,9 +825,9 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100
+ class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center"> @@ -937,46 +941,6 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
-
- -
-