From 704ddf2968ef4dbaf5c836fca2c9ceb30964e925 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:53:57 +0200 Subject: [PATCH] improved hetzner features --- app/Actions/Server/DeleteServer.php | 12 +- .../Security/CloudProviderTokenForm.php | 99 ++++++++++++ app/Livewire/Security/CloudProviderTokens.php | 79 ++-------- .../Server/CloudProviderToken/Show.php | 144 ++++++++++++++++++ app/Livewire/Server/Create.php | 8 + app/Livewire/Server/New/ByHetzner.php | 99 +++++------- app/Models/CloudProviderToken.php | 10 ++ app/Models/Server.php | 6 + ...oud_provider_token_id_to_servers_table.php | 29 ++++ .../views/components/modal-input.blade.php | 13 +- .../views/components/server/sidebar.blade.php | 5 + .../cloud-provider-token-form.blade.php | 43 ++++++ .../security/cloud-provider-tokens.blade.php | 36 +---- .../cloud-provider-token/show.blade.php | 61 ++++++++ .../views/livewire/server/create.blade.php | 2 +- .../livewire/server/new/by-hetzner.blade.php | 65 ++++---- routes/web.php | 2 + 17 files changed, 514 insertions(+), 199 deletions(-) create mode 100644 app/Livewire/Security/CloudProviderTokenForm.php create mode 100644 app/Livewire/Server/CloudProviderToken/Show.php create mode 100644 database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php create mode 100644 resources/views/livewire/security/cloud-provider-token-form.blade.php create mode 100644 resources/views/livewire/server/cloud-provider-token/show.blade.php diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index db197a019..b7523714f 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -25,10 +25,14 @@ public function handle(Server $server, bool $deleteFromHetzner = false) private function deleteFromHetzner(Server $server): void { try { - // Get the cloud provider token for Hetzner - $token = CloudProviderToken::where('team_id', $server->team_id) - ->where('provider', 'hetzner') - ->first(); + // Use the server's associated token, or fallback to first available team token + $token = $server->cloudProviderToken; + + if (! $token) { + $token = CloudProviderToken::where('team_id', $server->team_id) + ->where('provider', 'hetzner') + ->first(); + } if (! $token) { ray('No Hetzner token found for team, skipping Hetzner deletion', [ diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php new file mode 100644 index 000000000..7affb1531 --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -0,0 +1,99 @@ +authorize('create', CloudProviderToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => 'required|string|in:hetzner,digitalocean', + 'token' => 'required|string', + 'name' => 'required|string|max:255', + ]; + } + + protected function messages(): array + { + return [ + 'provider.required' => 'Please select a cloud provider.', + 'provider.in' => 'Invalid cloud provider selected.', + 'token.required' => 'API token is required.', + 'name.required' => 'Token name is required.', + ]; + } + + private function validateToken(string $provider, string $token): bool + { + try { + if ($provider === 'hetzner') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ray($response); + + return $response->successful(); + } + + // Add other providers here in the future + // if ($provider === 'digitalocean') { ... } + + return false; + } catch (\Throwable $e) { + return false; + } + } + + public function addToken() + { + $this->validate(); + + try { + // Validate the token with the provider's API + if (! $this->validateToken($this->provider, $this->token)) { + return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); + } + + $savedToken = CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => $this->provider, + 'token' => $this->token, + 'name' => $this->name, + ]); + + $this->reset(['token', 'name']); + + // Dispatch event with token ID so parent components can react + $this->dispatch('tokenAdded', tokenId: $savedToken->id); + + $this->dispatch('success', 'Cloud provider token added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token-form'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index f5726e424..f05b3c0ca 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -4,7 +4,6 @@ use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Http; use Livewire\Component; class CloudProviderTokens extends Component @@ -13,34 +12,16 @@ class CloudProviderTokens extends Component public $tokens; - public string $provider = 'hetzner'; - - public string $token = ''; - - public string $name = ''; - public function mount() { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); } - protected function rules(): array + public function getListeners() { return [ - 'provider' => 'required|string|in:hetzner,digitalocean', - 'token' => 'required|string', - 'name' => 'required|string|max:255', - ]; - } - - protected function messages(): array - { - return [ - 'provider.required' => 'Please select a cloud provider.', - 'provider.in' => 'Invalid cloud provider selected.', - 'token.required' => 'API token is required.', - 'name.required' => 'Token name is required.', + 'tokenAdded' => 'loadTokens', ]; } @@ -49,60 +30,20 @@ public function loadTokens() $this->tokens = CloudProviderToken::ownedByCurrentTeam()->get(); } - private function validateToken(string $provider, string $token): bool - { - try { - if ($provider === 'hetzner') { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); - - return $response->successful(); - } - - // Add other providers here in the future - // if ($provider === 'digitalocean') { ... } - - return false; - } catch (\Throwable $e) { - return false; - } - } - - public function addNewToken() - { - $this->validate(); - - try { - $this->authorize('create', CloudProviderToken::class); - - // Validate the token with the provider's API - if (! $this->validateToken($this->provider, $this->token)) { - return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); - } - - CloudProviderToken::create([ - 'team_id' => currentTeam()->id, - 'provider' => $this->provider, - 'token' => $this->token, - 'name' => $this->name, - ]); - - $this->reset(['token', 'name']); - $this->loadTokens(); - - $this->dispatch('success', 'Cloud provider token added successfully.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function deleteToken(int $tokenId) { try { $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); + // Check if any servers are using this token + if ($token->hasServers()) { + $serverCount = $token->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; + } + $token->delete(); $this->loadTokens(); diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php new file mode 100644 index 000000000..6b22fddc6 --- /dev/null +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -0,0 +1,144 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + ]; + } + + public function loadTokens() + { + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + $this->loadTokens(); + } + + public function setCloudProviderToken($tokenId) + { + $ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId); + if (is_null($ownedToken)) { + $this->dispatch('error', 'You are not allowed to use this token.'); + + return; + } + try { + $this->authorize('update', $this->server); + + // Validate the token works and can access this specific server + $validationResult = $this->validateTokenForServer($ownedToken); + if (! $validationResult['valid']) { + $this->dispatch('error', $validationResult['error']); + + return; + } + + $this->server->cloudProviderToken()->associate($ownedToken); + $this->server->save(); + $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('refreshServerShow'); + } catch (\Exception $e) { + $this->server->refresh(); + $this->dispatch('error', $e->getMessage()); + } + } + + private function validateTokenForServer(CloudProviderToken $token): array + { + try { + // First, validate the token itself + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if (! $response->successful()) { + return [ + 'valid' => false, + 'error' => 'This token is invalid or has insufficient permissions.', + ]; + } + + // Check if this token can access the specific Hetzner server + if ($this->server->hetzner_server_id) { + $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this server. It may belong to a different Hetzner project.', + ]; + } + } + + return ['valid' => true]; + } catch (\Throwable $e) { + return [ + 'valid' => false, + 'error' => 'Failed to validate token: '.$e->getMessage(), + ]; + } + } + + public function validateToken() + { + try { + $token = $this->server->cloudProviderToken; + if (! $token) { + $this->dispatch('error', 'No Hetzner token is associated with this server.'); + + return; + } + + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if ($response->successful()) { + $this->dispatch('success', 'Hetzner token is valid and working.'); + } else { + $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 2d4ba4430..cf77664fe 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Team; use Livewire\Component; @@ -12,6 +13,8 @@ class Create extends Component public bool $limit_reached = false; + public bool $has_hetzner_tokens = false; + public function mount() { $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); @@ -21,6 +24,11 @@ public function mount() return; } $this->limit_reached = Team::serverLimitReached(); + + // Check if user has Hetzner tokens + $this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->exists(); } public function render() diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index b67411e17..d0a7582cd 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -34,12 +34,6 @@ class ByHetzner extends Component // Step 1: Token selection public ?int $selected_token_id = null; - public string $hetzner_token = ''; - - public bool $save_token = false; - - public ?string $token_name = null; - // Step 2: Server configuration public array $locations = []; @@ -64,31 +58,50 @@ class ByHetzner extends Component public function mount() { $this->authorize('viewAny', CloudProviderToken::class); - $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() - ->where('provider', 'hetzner') - ->get(); + $this->loadTokens(); $this->server_name = generate_random_name(); if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } } + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection() + { + $this->selected_token_id = null; + $this->current_step = 1; + } + + public function loadTokens() + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + // Refresh token list + $this->loadTokens(); + + // Auto-select the new token + $this->selected_token_id = $tokenId; + + // Automatically proceed to next step + $this->nextStep(); + } + protected function rules(): array { $rules = [ - 'selected_token_id' => 'nullable|integer', - 'hetzner_token' => 'required_without:selected_token_id|string', - 'save_token' => 'boolean', - 'token_name' => [ - 'nullable', - 'string', - 'max:255', - function ($attribute, $value, $fail) { - if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { - $fail('Please provide a name for the token.'); - } - }, - ], + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', ]; if ($this->current_step === 2) { @@ -108,8 +121,8 @@ function ($attribute, $value, $fail) { protected function messages(): array { return [ - 'hetzner_token.required_without' => 'Please provide a Hetzner API token or select a saved token.', - 'token_name.required_if' => 'Please provide a name for the token.', + 'selected_token_id.required' => 'Please select a Hetzner token.', + 'selected_token_id.exists' => 'Selected token not found.', ]; } @@ -139,50 +152,21 @@ private function getHetznerToken(): string return $token ? $token->token : ''; } - return $this->hetzner_token; + return ''; } public function nextStep() { - // Validate step 1 + // Validate step 1 - just need a token selected $this->validate([ - 'selected_token_id' => 'nullable|integer', - 'hetzner_token' => 'required_without:selected_token_id|string', - 'save_token' => 'boolean', - 'token_name' => [ - 'nullable', - 'string', - 'max:255', - function ($attribute, $value, $fail) { - if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { - $fail('Please provide a name for the token.'); - } - }, - ], + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', ]); try { $hetznerToken = $this->getHetznerToken(); if (! $hetznerToken) { - return $this->dispatch('error', 'Please provide a valid Hetzner API token.'); - } - - // Validate token if it's a new one - if (! $this->selected_token_id) { - if (! $this->validateHetznerToken($hetznerToken)) { - return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); - } - - // Save token if requested - if ($this->save_token) { - CloudProviderToken::create([ - 'team_id' => currentTeam()->id, - 'provider' => 'hetzner', - 'token' => $this->hetzner_token, - 'name' => $this->token_name, - ]); - } + return $this->dispatch('error', 'Please select a valid Hetzner token.'); } // Load Hetzner data @@ -424,6 +408,7 @@ public function submit() 'port' => 22, 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, 'hetzner_server_id' => $hetznerServer['id'], ]); diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 9ce216b25..607040269 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -17,6 +17,16 @@ public function team() return $this->belongsTo(Team::class); } + public function servers() + { + return $this->hasMany(Server::class); + } + + public function hasServers(): bool + { + return $this->servers()->exists(); + } + public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); diff --git a/app/Models/Server.php b/app/Models/Server.php index e30b10043..e1a004755 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -161,6 +161,7 @@ protected static function booted() 'user', 'description', 'private_key_id', + 'cloud_provider_token_id', 'team_id', 'hetzner_server_id', ]; @@ -890,6 +891,11 @@ public function privateKey() return $this->belongsTo(PrivateKey::class); } + public function cloudProviderToken() + { + return $this->belongsTo(CloudProviderToken::class); + } + public function muxFilename() { return 'mux_'.$this->uuid; diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php new file mode 100644 index 000000000..a25a4ce83 --- /dev/null +++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php @@ -0,0 +1,29 @@ +foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['cloud_provider_token_id']); + $table->dropColumn('cloud_provider_token_id'); + }); + } +}; diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index c15985d03..a9ad39871 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,8 +8,11 @@ 'content' => null, 'closeOutside' => true, 'minWidth' => '36rem', + 'isFullWidth' => false, ]) -
@if ($content)
@@ -17,13 +20,13 @@ class="relative w-auto h-auto" wire:ignore>
@else @if ($disabled) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isErrorButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isHighlightedButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @else - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @endif @endif