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

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

No files matched your search

+6
View File
@@ -1134,6 +1134,12 @@ class GlobalSearch extends Component
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);
@@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Security\CloudInitScript;
use App\Models\CloudInitScript;
use App\Rules\ValidCloudInitYaml;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public CloudInitScript $cloudInitScript;
public string $name = '';
public string $script = '';
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => ['required', 'string', new ValidCloudInitYaml],
];
}
protected function messages(): array
{
return [
'name.required' => 'Script name is required.',
'name.max' => 'Script name cannot exceed 255 characters.',
'script.required' => 'Cloud-init script content is required.',
];
}
public function mount(string $cloud_init_script_uuid): void
{
try {
$this->cloudInitScript = CloudInitScript::ownedByCurrentTeam()
->whereUuid($cloud_init_script_uuid)
->firstOrFail();
$this->authorize('view', $this->cloudInitScript);
$this->name = $this->cloudInitScript->name;
$this->script = $this->cloudInitScript->script;
} catch (AuthorizationException) {
abort(403, 'You do not have permission to view this cloud-init script.');
} catch (\Throwable) {
abort(404);
}
}
public function save(): void
{
$this->authorize('update', $this->cloudInitScript);
$this->validate();
$this->cloudInitScript->update([
'name' => $this->name,
'script' => $this->script,
]);
auditLog('ui.cloud_init_script.updated', [
'team_id' => currentTeam()->id,
'cloud_init_script_id' => $this->cloudInitScript->id,
'cloud_init_script_name' => $this->cloudInitScript->name,
]);
$this->dispatch('success', 'Cloud-init script updated successfully.');
}
public function delete(): mixed
{
$this->authorize('delete', $this->cloudInitScript);
$scriptId = $this->cloudInitScript->id;
$scriptName = $this->cloudInitScript->name;
$this->cloudInitScript->delete();
auditLog('ui.cloud_init_script.deleted', [
'team_id' => currentTeam()->id,
'cloud_init_script_id' => $scriptId,
'cloud_init_script_name' => $scriptName,
]);
return redirectRoute($this, 'security.cloud-init-scripts');
}
public function render()
{
return view('livewire.security.cloud-init-script.show');
}
}
@@ -27,6 +27,13 @@ class CloudInitScripts extends Component
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();
}
@@ -0,0 +1,177 @@
<?php
namespace App\Livewire\Security\CloudProviderToken;
use App\Models\CloudProviderToken;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public CloudProviderToken $cloudProviderToken;
public string $name = '';
public ?string $description = null;
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
];
}
protected function messages(): array
{
return [
'name.required' => 'Token name is required.',
];
}
public function mount(string $cloud_token_uuid): void
{
try {
$this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam()
->whereUuid($cloud_token_uuid)
->firstOrFail();
$this->authorize('view', $this->cloudProviderToken);
$this->name = $this->cloudProviderToken->name;
$this->description = $this->cloudProviderToken->description;
} catch (AuthorizationException) {
abort(403, 'You do not have permission to view this cloud token.');
} catch (\Throwable) {
abort(404);
}
}
public function save(): void
{
$this->authorize('update', $this->cloudProviderToken);
$this->validate();
$description = trim($this->description ?? '');
$this->cloudProviderToken->update([
'name' => $this->name,
'description' => $description === '' ? null : $description,
]);
auditLog('ui.cloud_token.updated', [
'team_id' => currentTeam()->id,
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
'cloud_token_name' => $this->cloudProviderToken->name,
'provider' => $this->cloudProviderToken->provider,
]);
$this->dispatch('success', 'Cloud provider token updated.');
}
public function validateToken(): void
{
$this->authorize('view', $this->cloudProviderToken);
$isValid = match ($this->cloudProviderToken->provider) {
'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token),
'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token),
'vultr' => $this->validateVultrToken($this->cloudProviderToken->token),
default => false,
};
$providerName = $this->providerName();
$this->dispatch(
$isValid ? 'success' : 'error',
$isValid
? "{$providerName} token is valid."
: "{$providerName} token validation failed. Please check the token."
);
auditLog('ui.cloud_token.validated', [
'team_id' => currentTeam()->id,
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
'cloud_token_name' => $this->cloudProviderToken->name,
'provider' => $this->cloudProviderToken->provider,
'valid' => $isValid,
]);
}
public function delete(): mixed
{
$this->authorize('delete', $this->cloudProviderToken);
if ($this->cloudProviderToken->hasServers()) {
$serverCount = $this->cloudProviderToken->servers()->count();
$this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first.");
return null;
}
auditLog('ui.cloud_token.deleted', [
'team_id' => currentTeam()->id,
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
'cloud_token_name' => $this->cloudProviderToken->name,
'provider' => $this->cloudProviderToken->provider,
]);
$this->cloudProviderToken->delete();
return redirectRoute($this, 'security.cloud-tokens');
}
public function providerName(): string
{
return match ($this->cloudProviderToken->provider) {
'digitalocean' => 'DigitalOcean',
'vultr' => 'Vultr',
default => 'Hetzner',
};
}
private function validateHetznerToken(string $token): bool
{
try {
return Http::withToken($token)
->timeout(10)
->get('https://api.hetzner.cloud/v1/servers?per_page=1')
->successful();
} catch (\Throwable) {
return false;
}
}
private function validateDigitalOceanToken(string $token): bool
{
try {
return Http::withToken($token)
->timeout(10)
->get('https://api.digitalocean.com/v2/account')
->successful();
} catch (\Throwable) {
return false;
}
}
private function validateVultrToken(string $token): bool
{
try {
return Http::withToken($token)
->timeout(10)
->get('https://api.vultr.com/v2/account')
->successful();
} catch (\Throwable) {
return false;
}
}
public function render()
{
return view('livewire.security.cloud-provider-token.show');
}
}
@@ -22,6 +22,8 @@ class CloudProviderTokenForm extends Component
public string $name = '';
public ?string $description = null;
public function mount()
{
try {
@@ -37,6 +39,7 @@ class CloudProviderTokenForm extends Component
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'token' => 'required|string',
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
];
}
@@ -94,11 +97,14 @@ class CloudProviderTokenForm extends Component
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 @@ class CloudProviderTokenForm extends Component
'provider' => $savedToken->provider,
]);
$this->reset(['token', 'name']);
$this->reset(['token', 'name', 'description']);
// Dispatch event with token ID so parent components can react
$this->dispatch('tokenAdded', tokenId: $savedToken->id);
@@ -43,22 +43,6 @@ class Create extends Component
);
}
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 @@ class Create extends Component
}
}
private function setKeyData(array $keyData)
{
$this->name = $keyData['name'];
$this->description = $keyData['description'];
$this->value = $keyData['private_key'];
$this->publicKey = $keyData['public_key'];
}
private function validatePrivateKey()
{
$validationResult = PrivateKey::validateAndExtractPublicKey($this->value);
@@ -10,6 +10,31 @@ class Index extends Component
{
use AuthorizesRequests;
public function generatePrivateKey(string $type)
{
try {
$this->authorize('create', PrivateKey::class);
if (! in_array($type, ['ed25519', 'rsa'], true)) {
$this->dispatch('error', 'Invalid private key type.');
return;
}
$keyData = PrivateKey::generateNewKeyPair($type);
$privateKey = PrivateKey::createAndStore([
'name' => $keyData['name'],
'description' => $keyData['description'],
'private_key' => $keyData['private_key'],
'team_id' => currentTeam()->id,
]);
return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get();
+18 -4
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Security\PrivateKey;
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 @@ class Show extends Component
}
}
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 @@ class Show extends Component
{
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');
+10 -1
View File
@@ -11,12 +11,21 @@ class Create extends Component
{
public $private_keys = [];
public ?string $selectedType = null;
public ?string $selectedTokenUuid = null;
public bool $limit_reached = false;
public bool $has_hetzner_tokens = false;
public function mount()
public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void
{
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true)
? $selectedType
: null;
$this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null;
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
if (! isCloud()) {
$this->limit_reached = false;
+55
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');
}
}
+71 -9
View File
@@ -12,6 +12,7 @@ use App\Rules\ValidCloudInitYaml;
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 @@ class ByDigitalOcean extends Component
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 @@ class ByDigitalOcean extends Component
];
}
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 @@ class ByDigitalOcean extends Component
]);
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 @@ class ByDigitalOcean extends Component
$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) {
+69 -11
View File
@@ -12,6 +12,7 @@ use App\Rules\ValidCloudInitYaml;
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 @@ class ByHetzner extends Component
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 @@ class ByHetzner extends Component
];
}
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 @@ class ByHetzner extends Component
]);
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 @@ class ByHetzner extends Component
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 @@ class ByHetzner extends Component
$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'] ?? '');
+41
View File
@@ -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 @@ class ByIp extends Component
]);
}
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!');
+94 -9
View File
@@ -12,6 +12,7 @@ use App\Rules\ValidCloudInitYaml;
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 @@ class ByVultr extends Component
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 @@ class ByVultr extends Component
];
}
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 @@ class ByVultr extends Component
]);
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 @@ class ByVultr extends Component
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 @@ class ByVultr extends Component
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 @@ class ByVultr extends Component
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 @@ class ByVultr extends Component
$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);
+27
View File
@@ -55,6 +55,33 @@ class Show extends Component
}
}
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 {
+3
View File
@@ -828,6 +828,7 @@ class Show extends Component
$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 @@ class Show extends Component
$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 @@ class Show extends Component
$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);
+1 -3
View File
@@ -2,9 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudInitScript extends Model
class CloudInitScript extends BaseModel
{
protected $fillable = [
'team_id',
+1
View File
@@ -13,6 +13,7 @@ class CloudProviderToken extends BaseModel
'provider',
'token',
'name',
'description',
];
protected $hidden = [
+1 -1
View File
@@ -291,7 +291,7 @@ class PrivateKey extends BaseModel
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)
+1
View File
@@ -22,6 +22,7 @@ class Button extends Component
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) {