diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php new file mode 100644 index 000000000..f35a3a806 --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -0,0 +1,119 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } + + 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.', + ]; + } + + 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::findOrFail($tokenId); + $this->authorize('delete', $token); + + $token->delete(); + $this->loadTokens(); + + $this->dispatch('success', 'Cloud provider token deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-tokens'); + } +} diff --git a/app/Livewire/Security/CloudTokens.php b/app/Livewire/Security/CloudTokens.php new file mode 100644 index 000000000..d6d1333f1 --- /dev/null +++ b/app/Livewire/Security/CloudTokens.php @@ -0,0 +1,13 @@ +authorize('viewAny', CloudProviderToken::class); + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + protected function rules(): array + { + return [ + 'selected_token_id' => 'nullable|integer', + 'hetzner_token' => 'required_without:selected_token_id|string', + 'save_token' => 'boolean', + 'token_name' => 'required_if:save_token,true|nullable|string|max:255', + ]; + } + + 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.', + ]; + } + + public function selectToken(int $tokenId) + { + $this->selected_token_id = $tokenId; + } + + private function validateHetznerToken(string $token): bool + { + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + + public function submit() + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + // Determine which token to use + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + if (! $token) { + return $this->dispatch('error', 'Selected token not found.'); + } + $hetznerToken = $token->token; + } else { + $hetznerToken = $this->hetzner_token; + + // Validate the new token before saving + if (! $this->validateHetznerToken($hetznerToken)) { + return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); + } + + // If saving the new token + if ($this->save_token) { + CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => 'hetzner', + 'token' => $this->hetzner_token, + 'name' => $this->token_name, + ]); + } + } + + // TODO: Actual Hetzner server provisioning will be implemented in future phase + // The $hetznerToken variable contains the token to use + return $this->dispatch('success', 'Hetzner token validated successfully! Server provisioning coming soon.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-hetzner'); + } +} diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php new file mode 100644 index 000000000..9ce216b25 --- /dev/null +++ b/app/Models/CloudProviderToken.php @@ -0,0 +1,31 @@ + 'encrypted', + ]; + + public function team() + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } + + public function scopeForProvider($query, string $provider) + { + return $query->where('provider', $provider); + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index 51fdeffa4..6945bb918 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -258,6 +258,11 @@ public function privateKeys() return $this->hasMany(PrivateKey::class); } + public function cloudProviderTokens() + { + return $this->hasMany(CloudProviderToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Policies/CloudProviderTokenPolicy.php b/app/Policies/CloudProviderTokenPolicy.php new file mode 100644 index 000000000..b7b108ba8 --- /dev/null +++ b/app/Policies/CloudProviderTokenPolicy.php @@ -0,0 +1,65 @@ +isAdmin(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } +} diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php new file mode 100644 index 000000000..2c92b0e19 --- /dev/null +++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('provider'); + $table->text('token'); + $table->string('name')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_provider_tokens'); + } +}; diff --git a/resources/views/components/security/navbar.blade.php b/resources/views/components/security/navbar.blade.php index e389ad6b7..b0dfdd242 100644 --- a/resources/views/components/security/navbar.blade.php +++ b/resources/views/components/security/navbar.blade.php @@ -6,8 +6,13 @@ + @can('viewAny', App\Models\CloudProviderToken::class) + + + + @endcan - + diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php new file mode 100644 index 000000000..d29631c4c --- /dev/null +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -0,0 +1,66 @@ +
+

Cloud Provider Tokens

+
Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.). Tokens are saved encrypted and shared with your team.
+ +

New Token

+ @can('create', App\Models\CloudProviderToken::class) +
+
+
+ + + + +
+
+ +
+
+
+
+ +
+ Add Token +
+
+ @endcan + +

Saved Tokens

+
+ @forelse ($tokens as $savedToken) +
+
+ + {{ strtoupper($savedToken->provider) }} + + {{ $savedToken->name }} +
+
Token: ***{{ substr($savedToken->token, -4) }}
+
Created: {{ $savedToken->created_at->diffForHumans() }}
+ + @can('delete', $savedToken) + + @endcan +
+ @empty +
+
No cloud provider tokens found.
+
+ @endforelse +
+
diff --git a/resources/views/livewire/security/cloud-tokens.blade.php b/resources/views/livewire/security/cloud-tokens.blade.php new file mode 100644 index 000000000..2edbcd30f --- /dev/null +++ b/resources/views/livewire/security/cloud-tokens.blade.php @@ -0,0 +1,7 @@ +
+ + Cloud Tokens | Coolify + + + +
diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index acab92374..619a827e7 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -1,3 +1,23 @@
- +
+ @can('viewAny', App\Models\CloudProviderToken::class) +
+

Add Server from Cloud Provider

+
+ + + +
+
+ +
+ @endcan + +
+

Add Server by IP Address

+ +
+
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php new file mode 100644 index 000000000..83de355e3 --- /dev/null +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -0,0 +1,52 @@ +
+ @if ($limit_reached) + + @else +
+ @if ($available_tokens->count() > 0) +
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
OR
+ @endif + +
+ +
+ +
+ +
+ + @if ($save_token) +
+ +
+ @endif + + + Continue + +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index fd2ed8730..e967586c4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; +use App\Livewire\Security\CloudTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\Server\Advanced as ServerAdvanced; @@ -271,6 +272,7 @@ // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); 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/api-tokens', ApiTokens::class)->name('security.api-tokens'); });