improved hetzner features
This commit is contained in:
parent
c9e6418542
commit
704ddf2968
17 changed files with 514 additions and 199 deletions
|
|
@ -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', [
|
||||
|
|
|
|||
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal file
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudProviderTokenForm extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public bool $modal_mode = false;
|
||||
|
||||
public string $provider = 'hetzner';
|
||||
|
||||
public string $token = '';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal file
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server\CloudProviderToken;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public $cloudProviderTokens = [];
|
||||
|
||||
public $parameters = [];
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -8,8 +8,11 @@
|
|||
'content' => null,
|
||||
'closeOutside' => true,
|
||||
'minWidth' => '36rem',
|
||||
'isFullWidth' => false,
|
||||
])
|
||||
<div x-data="{ modalOpen: false }" :class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
|
||||
<div x-data="{ modalOpen: false }"
|
||||
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
|
||||
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
|
||||
class="relative w-auto h-auto" wire:ignore>
|
||||
@if ($content)
|
||||
<div @click="modalOpen=true">
|
||||
|
|
@ -17,13 +20,13 @@ class="relative w-auto h-auto" wire:ignore>
|
|||
</div>
|
||||
@else
|
||||
@if ($disabled)
|
||||
<x-forms.button isError disabled>{{ $buttonTitle }}</x-forms.button>
|
||||
<x-forms.button isError disabled @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
|
||||
@elseif ($isErrorButton)
|
||||
<x-forms.button isError @click="modalOpen=true">{{ $buttonTitle }}</x-forms.button>
|
||||
<x-forms.button isError @click="modalOpen=true" @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
|
||||
@elseif ($isHighlightedButton)
|
||||
<x-forms.button isHighlighted @click="modalOpen=true">{{ $buttonTitle }}</x-forms.button>
|
||||
<x-forms.button isHighlighted @click="modalOpen=true" @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
|
||||
@else
|
||||
<x-forms.button @click="modalOpen=true">{{ $buttonTitle }}</x-forms.button>
|
||||
<x-forms.button @click="modalOpen=true" @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
|
||||
@endif
|
||||
@endif
|
||||
<template x-teleport="body">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@
|
|||
<a class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}"
|
||||
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key
|
||||
</a>
|
||||
@if ($server->hetzner_server_id)
|
||||
<a class="menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
|
||||
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}">Hetzner Token
|
||||
</a>
|
||||
@endif
|
||||
<a class="menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}"
|
||||
href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}">CA Certificate
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<div class="w-full">
|
||||
<form class="flex flex-col gap-2 {{ $modal_mode ? 'w-full' : '' }}" wire:submit='addToken'>
|
||||
@if ($modal_mode)
|
||||
{{-- Modal layout: vertical, compact --}}
|
||||
@if (!isset($provider) || empty($provider) || $provider === '')
|
||||
<x-forms.select required id="provider" label="Provider">
|
||||
<option value="hetzner">Hetzner</option>
|
||||
<option value="digitalocean">DigitalOcean</option>
|
||||
</x-forms.select>
|
||||
@else
|
||||
<input type="hidden" wire:model="provider" />
|
||||
@endif
|
||||
|
||||
<x-forms.input required id="name" label="Token Name"
|
||||
placeholder="e.g., Production Hetzner. tip: add Hetzner project name in it" />
|
||||
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token"
|
||||
helper="Your {{ ucfirst($provider) }} Cloud API token. You can create one in your <a href='{{ $provider === 'hetzner' ? 'https://console.hetzner.cloud/' : '#' }}' target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a>." />
|
||||
|
||||
<x-forms.button type="submit">Add Token</x-forms.button>
|
||||
@else
|
||||
{{-- Full page layout: horizontal, spacious --}}
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div class="w-64">
|
||||
<x-forms.select required id="provider" label="Provider">
|
||||
<option value="hetzner">Hetzner</option>
|
||||
<option value="digitalocean">DigitalOcean</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production Hetzner" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
placeholder="Enter your API token" />
|
||||
</div>
|
||||
<x-forms.button type="submit">Add Token</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,28 +1,10 @@
|
|||
<div>
|
||||
<h2>Cloud Provider Tokens</h2>
|
||||
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.). Tokens are saved encrypted and shared with your team.</div>
|
||||
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.).</div>
|
||||
|
||||
<h3>New Token</h3>
|
||||
@can('create', App\Models\CloudProviderToken::class)
|
||||
<form class="flex flex-col gap-2" wire:submit='addNewToken'>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div class="w-64">
|
||||
<x-forms.select required id="provider" label="Provider">
|
||||
<option value="hetzner">Hetzner</option>
|
||||
<option value="digitalocean">DigitalOcean</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production Hetzner" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
|
||||
</div>
|
||||
<x-forms.button type="submit">Add Token</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="false" />
|
||||
@endcan
|
||||
|
||||
<h3 class="py-4">Saved Tokens</h3>
|
||||
|
|
@ -36,25 +18,17 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
|
|||
</span>
|
||||
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span>
|
||||
</div>
|
||||
<div class="text-sm">Token: ***{{ substr($savedToken->token, -4) }}</div>
|
||||
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
|
||||
|
||||
@can('delete', $savedToken)
|
||||
<x-modal-confirmation
|
||||
title="Confirm Token Deletion?"
|
||||
isErrorButton
|
||||
buttonTitle="Delete Token"
|
||||
submitAction="deleteToken({{ $savedToken->id }})"
|
||||
:actions="[
|
||||
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
|
||||
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
|
||||
'This cloud provider token will be permanently deleted.',
|
||||
'Any servers using this token will need to be reconfigured.',
|
||||
]"
|
||||
confirmationText="{{ $savedToken->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the token name below"
|
||||
shortConfirmationLabel="Token Name"
|
||||
:confirmWithPassword="false"
|
||||
step2ButtonText="Delete Token"
|
||||
/>
|
||||
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
|
||||
@endcan
|
||||
</div>
|
||||
@empty
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
|
||||
<div class="w-full">
|
||||
@if ($server->hetzner_server_id)
|
||||
<div class="flex items-end gap-2">
|
||||
<h2>Hetzner Token</h2>
|
||||
@can('create', App\Models\CloudProviderToken::class)
|
||||
<x-modal-input buttonTitle="+ Add" title="Add Hetzner Token">
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted
|
||||
wire:click.prevent='validateToken'>
|
||||
Validate token
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4">Change your server's Hetzner token.</div>
|
||||
<div class="grid xl:grid-cols-2 grid-cols-1 gap-2">
|
||||
@forelse ($cloudProviderTokens as $token)
|
||||
<div
|
||||
class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center flex flex-col gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="box-title">{{ $token->name }}</div>
|
||||
<div class="box-description">
|
||||
Created {{ $token->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (data_get($server, 'cloudProviderToken.id') !== $token->id)
|
||||
<x-forms.button canGate="update" :canResource="$server" class="w-full"
|
||||
wire:click='setCloudProviderToken({{ $token->id }})'>
|
||||
Use this token
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button class="w-full" disabled>
|
||||
Currently used
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div>No Hetzner tokens found. </div>
|
||||
@endforelse
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-end gap-2">
|
||||
<h2>Hetzner Token</h2>
|
||||
</div>
|
||||
<div class="pb-4">This server was not created through Hetzner Cloud integration.</div>
|
||||
<div class="p-4 border rounded-md dark:border-coolgray-300 dark:bg-coolgray-100">
|
||||
<p class="dark:text-neutral-400">
|
||||
Only servers created through Hetzner Cloud can have their tokens managed here.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<x-modal-input title="Connect to Hetzner">
|
||||
<x-modal-input title="Connect a Hetzner Server">
|
||||
<x-slot:button-title>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
|
|||
|
|
@ -3,40 +3,37 @@
|
|||
<x-limit-reached name="servers" />
|
||||
@else
|
||||
@if ($current_step === 1)
|
||||
<form class="flex flex-col w-full gap-2" wire:submit.prevent="nextStep">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
@if ($available_tokens->count() > 0)
|
||||
<div>
|
||||
<x-forms.select label="Use Saved Token" id="selected_token_id"
|
||||
wire:change="selectToken($event.target.value)">
|
||||
<option value="">Select a saved token...</option>
|
||||
@foreach ($available_tokens as $token)
|
||||
<option value="{{ $token->id }}">
|
||||
{{ $token->name ?? 'Hetzner Token' }} (***{{ substr($token->token, -4) }})
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.select label="Select Hetzner Token" id="selected_token_id"
|
||||
wire:change="selectToken($event.target.value)" required>
|
||||
<option value="">Select a saved token...</option>
|
||||
@foreach ($available_tokens as $token)
|
||||
<option value="{{ $token->id }}">
|
||||
{{ $token->name ?? 'Hetzner Token' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class"
|
||||
wire:click="nextStep" :disabled="!$selected_token_id">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-sm dark:text-neutral-500 py-2">OR</div>
|
||||
|
||||
<div class="text-center text-sm dark:text-neutral-500">OR</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<x-forms.input type="password" id="hetzner_token" label="New Hetzner API Token"
|
||||
helper="Your Hetzner Cloud API token. You can create one in your <a href='https://console.hetzner.cloud/' target='_blank' class='underline dark:text-white'>Hetzner Cloud Console</a>." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.checkbox id="save_token" label="Save this token for my team" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.input id="token_name" label="Token Name" placeholder="e.g., Production Hetzner"
|
||||
helper="Give this token a friendly name to identify it later." />
|
||||
</div>
|
||||
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class" type="submit">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</form>
|
||||
<x-modal-input isFullWidth
|
||||
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Hetzner Token' }}"
|
||||
title="Add Hetzner Token">
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@elseif ($current_step === 2)
|
||||
@if ($loading_data)
|
||||
<div class="flex items-center justify-center py-8">
|
||||
|
|
@ -66,7 +63,9 @@
|
|||
<div>
|
||||
<x-forms.select label="Server Type" id="selected_server_type"
|
||||
wire:model.live="selected_server_type" required :disabled="!$selected_location">
|
||||
<option value="">{{ $selected_location ? 'Select a server type...' : 'Select a location first' }}</option>
|
||||
<option value="">
|
||||
{{ $selected_location ? 'Select a server type...' : 'Select a location first' }}
|
||||
</option>
|
||||
@foreach ($this->availableServerTypes as $serverType)
|
||||
<option value="{{ $serverType['name'] }}">
|
||||
{{ $serverType['description'] }} -
|
||||
|
|
@ -87,7 +86,9 @@
|
|||
|
||||
<div>
|
||||
<x-forms.select label="Image" id="selected_image" required :disabled="!$selected_server_type">
|
||||
<option value="">{{ $selected_server_type ? 'Select an image...' : 'Select a server type first' }}</option>
|
||||
<option value="">
|
||||
{{ $selected_server_type ? 'Select an image...' : 'Select a server type first' }}
|
||||
</option>
|
||||
@foreach ($this->availableImages as $image)
|
||||
<option value="{{ $image['id'] }}">
|
||||
{{ $image['description'] ?? $image['name'] }}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
|
||||
use App\Livewire\Server\Charts as ServerCharts;
|
||||
use App\Livewire\Server\CloudflareTunnel;
|
||||
use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow;
|
||||
use App\Livewire\Server\Delete as DeleteServer;
|
||||
use App\Livewire\Server\Destinations as ServerDestinations;
|
||||
use App\Livewire\Server\DockerCleanup;
|
||||
|
|
@ -248,6 +249,7 @@
|
|||
Route::get('/', ServerShow::class)->name('server.show');
|
||||
Route::get('/advanced', ServerAdvanced::class)->name('server.advanced');
|
||||
Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key');
|
||||
Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token');
|
||||
Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate');
|
||||
Route::get('/resources', ResourcesShow::class)->name('server.resources');
|
||||
Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel');
|
||||
|
|
|
|||
Loading…
Reference in a new issue