improved hetzner features

This commit is contained in:
Andras Bacsai
2025-10-09 12:53:57 +02:00
parent c9e6418542
commit 704ddf2968
17 changed files with 514 additions and 199 deletions

View File

@@ -25,10 +25,14 @@ class DeleteServer
private function deleteFromHetzner(Server $server): void private function deleteFromHetzner(Server $server): void
{ {
try { try {
// Get the cloud provider token for Hetzner // Use the server's associated token, or fallback to first available team token
$token = CloudProviderToken::where('team_id', $server->team_id) $token = $server->cloudProviderToken;
->where('provider', 'hetzner')
->first(); if (! $token) {
$token = CloudProviderToken::where('team_id', $server->team_id)
->where('provider', 'hetzner')
->first();
}
if (! $token) { if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [ ray('No Hetzner token found for team, skipping Hetzner deletion', [

View 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');
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Livewire\Security;
use App\Models\CloudProviderToken; use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Component; use Livewire\Component;
class CloudProviderTokens extends Component class CloudProviderTokens extends Component
@@ -13,34 +12,16 @@ class CloudProviderTokens extends Component
public $tokens; public $tokens;
public string $provider = 'hetzner';
public string $token = '';
public string $name = '';
public function mount() public function mount()
{ {
$this->authorize('viewAny', CloudProviderToken::class); $this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens(); $this->loadTokens();
} }
protected function rules(): array public function getListeners()
{ {
return [ return [
'provider' => 'required|string|in:hetzner,digitalocean', 'tokenAdded' => 'loadTokens',
'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.',
]; ];
} }
@@ -49,60 +30,20 @@ class CloudProviderTokens extends Component
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get(); $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) public function deleteToken(int $tokenId)
{ {
try { try {
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token); $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(); $token->delete();
$this->loadTokens(); $this->loadTokens();

View 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');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Team; use App\Models\Team;
use Livewire\Component; use Livewire\Component;
@@ -12,6 +13,8 @@ class Create extends Component
public bool $limit_reached = false; public bool $limit_reached = false;
public bool $has_hetzner_tokens = false;
public function mount() public function mount()
{ {
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); $this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
@@ -21,6 +24,11 @@ class Create extends Component
return; return;
} }
$this->limit_reached = Team::serverLimitReached(); $this->limit_reached = Team::serverLimitReached();
// Check if user has Hetzner tokens
$this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->exists();
} }
public function render() public function render()

View File

@@ -34,12 +34,6 @@ class ByHetzner extends Component
// Step 1: Token selection // Step 1: Token selection
public ?int $selected_token_id = null; public ?int $selected_token_id = null;
public string $hetzner_token = '';
public bool $save_token = false;
public ?string $token_name = null;
// Step 2: Server configuration // Step 2: Server configuration
public array $locations = []; public array $locations = [];
@@ -64,31 +58,50 @@ class ByHetzner extends Component
public function mount() public function mount()
{ {
$this->authorize('viewAny', CloudProviderToken::class); $this->authorize('viewAny', CloudProviderToken::class);
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam() $this->loadTokens();
->where('provider', 'hetzner')
->get();
$this->server_name = generate_random_name(); $this->server_name = generate_random_name();
if ($this->private_keys->count() > 0) { if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id; $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 protected function rules(): array
{ {
$rules = [ $rules = [
'selected_token_id' => 'nullable|integer', 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
'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.');
}
},
],
]; ];
if ($this->current_step === 2) { if ($this->current_step === 2) {
@@ -108,8 +121,8 @@ class ByHetzner extends Component
protected function messages(): array protected function messages(): array
{ {
return [ return [
'hetzner_token.required_without' => 'Please provide a Hetzner API token or select a saved token.', 'selected_token_id.required' => 'Please select a Hetzner token.',
'token_name.required_if' => 'Please provide a name for the token.', 'selected_token_id.exists' => 'Selected token not found.',
]; ];
} }
@@ -139,50 +152,21 @@ class ByHetzner extends Component
return $token ? $token->token : ''; return $token ? $token->token : '';
} }
return $this->hetzner_token; return '';
} }
public function nextStep() public function nextStep()
{ {
// Validate step 1 // Validate step 1 - just need a token selected
$this->validate([ $this->validate([
'selected_token_id' => 'nullable|integer', 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
'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.');
}
},
],
]); ]);
try { try {
$hetznerToken = $this->getHetznerToken(); $hetznerToken = $this->getHetznerToken();
if (! $hetznerToken) { if (! $hetznerToken) {
return $this->dispatch('error', 'Please provide a valid Hetzner API token.'); return $this->dispatch('error', 'Please select a valid Hetzner 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,
]);
}
} }
// Load Hetzner data // Load Hetzner data
@@ -424,6 +408,7 @@ class ByHetzner extends Component
'port' => 22, 'port' => 22,
'team_id' => currentTeam()->id, 'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id, 'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'], 'hetzner_server_id' => $hetznerServer['id'],
]); ]);

View File

@@ -17,6 +17,16 @@ class CloudProviderToken extends Model
return $this->belongsTo(Team::class); 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 = ['*']) public static function ownedByCurrentTeam(array $select = ['*'])
{ {
$selectArray = collect($select)->concat(['id']); $selectArray = collect($select)->concat(['id']);

View File

@@ -161,6 +161,7 @@ class Server extends BaseModel
'user', 'user',
'description', 'description',
'private_key_id', 'private_key_id',
'cloud_provider_token_id',
'team_id', 'team_id',
'hetzner_server_id', 'hetzner_server_id',
]; ];
@@ -890,6 +891,11 @@ $schema://$host {
return $this->belongsTo(PrivateKey::class); return $this->belongsTo(PrivateKey::class);
} }
public function cloudProviderToken()
{
return $this->belongsTo(CloudProviderToken::class);
}
public function muxFilename() public function muxFilename()
{ {
return 'mux_'.$this->uuid; return 'mux_'.$this->uuid;

View File

@@ -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');
});
}
};

View File

@@ -8,8 +8,11 @@
'content' => null, 'content' => null,
'closeOutside' => true, 'closeOutside' => true,
'minWidth' => '36rem', '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> class="relative w-auto h-auto" wire:ignore>
@if ($content) @if ($content)
<div @click="modalOpen=true"> <div @click="modalOpen=true">
@@ -17,13 +20,13 @@
</div> </div>
@else @else
@if ($disabled) @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) @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) @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 @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
@endif @endif
<template x-teleport="body"> <template x-teleport="body">

View File

@@ -9,6 +9,11 @@
<a class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}" <a class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}"
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key
</a> </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' : '' }}" <a class="menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}"
href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}">CA Certificate href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}">CA Certificate
</a> </a>

View File

@@ -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>

View File

@@ -1,28 +1,10 @@
<div> <div>
<h2>Cloud Provider Tokens</h2> <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> <h3>New Token</h3>
@can('create', App\Models\CloudProviderToken::class) @can('create', App\Models\CloudProviderToken::class)
<form class="flex flex-col gap-2" wire:submit='addNewToken'> <livewire:security.cloud-provider-token-form :modal_mode="false" />
<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>
@endcan @endcan
<h3 class="py-4">Saved Tokens</h3> <h3 class="py-4">Saved Tokens</h3>
@@ -36,25 +18,17 @@
</span> </span>
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span> <span class="font-bold dark:text-white">{{ $savedToken->name }}</span>
</div> </div>
<div class="text-sm">Token: ***{{ substr($savedToken->token, -4) }}</div>
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div> <div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
@can('delete', $savedToken) @can('delete', $savedToken)
<x-modal-confirmation <x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
title="Confirm Token Deletion?" submitAction="deleteToken({{ $savedToken->id }})" :actions="[
isErrorButton
buttonTitle="Delete Token"
submitAction="deleteToken({{ $savedToken->id }})"
:actions="[
'This cloud provider token will be permanently deleted.', 'This cloud provider token will be permanently deleted.',
'Any servers using this token will need to be reconfigured.', 'Any servers using this token will need to be reconfigured.',
]" ]"
confirmationText="{{ $savedToken->name }}" confirmationText="{{ $savedToken->name }}"
confirmationLabel="Please confirm the deletion by entering the token name below" confirmationLabel="Please confirm the deletion by entering the token name below"
shortConfirmationLabel="Token Name" shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
:confirmWithPassword="false"
step2ButtonText="Delete Token"
/>
@endcan @endcan
</div> </div>
@empty @empty

View File

@@ -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>

View File

@@ -3,7 +3,7 @@
@can('viewAny', App\Models\CloudProviderToken::class) @can('viewAny', App\Models\CloudProviderToken::class)
<div> <div>
<div class="flex gap-2 flex-wrap"> <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> <x-slot:button-title>
<div class="flex items-center gap-2"> <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"> <svg class="w-5 h-5" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">

View File

@@ -3,40 +3,37 @@
<x-limit-reached name="servers" /> <x-limit-reached name="servers" />
@else @else
@if ($current_step === 1) @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) @if ($available_tokens->count() > 0)
<div> <div class="flex gap-2">
<x-forms.select label="Use Saved Token" id="selected_token_id" <div class="flex-1">
wire:change="selectToken($event.target.value)"> <x-forms.select label="Select Hetzner Token" id="selected_token_id"
<option value="">Select a saved token...</option> wire:change="selectToken($event.target.value)" required>
@foreach ($available_tokens as $token) <option value="">Select a saved token...</option>
<option value="{{ $token->id }}"> @foreach ($available_tokens as $token)
{{ $token->name ?? 'Hetzner Token' }} (***{{ substr($token->token, -4) }}) <option value="{{ $token->id }}">
</option> {{ $token->name ?? 'Hetzner Token' }}
@endforeach </option>
</x-forms.select> @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>
<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 @endif
<div> <x-modal-input isFullWidth
<x-forms.input type="password" id="hetzner_token" label="New Hetzner API Token" buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Hetzner 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>." /> title="Add Hetzner Token">
</div> <livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
</x-modal-input>
<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>
@elseif ($current_step === 2) @elseif ($current_step === 2)
@if ($loading_data) @if ($loading_data)
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
@@ -66,7 +63,9 @@
<div> <div>
<x-forms.select label="Server Type" id="selected_server_type" <x-forms.select label="Server Type" id="selected_server_type"
wire:model.live="selected_server_type" required :disabled="!$selected_location"> 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) @foreach ($this->availableServerTypes as $serverType)
<option value="{{ $serverType['name'] }}"> <option value="{{ $serverType['name'] }}">
{{ $serverType['description'] }} - {{ $serverType['description'] }} -
@@ -87,7 +86,9 @@
<div> <div>
<x-forms.select label="Image" id="selected_image" required :disabled="!$selected_server_type"> <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) @foreach ($this->availableImages as $image)
<option value="{{ $image['id'] }}"> <option value="{{ $image['id'] }}">
{{ $image['description'] ?? $image['name'] }} {{ $image['description'] ?? $image['name'] }}

View File

@@ -41,6 +41,7 @@ use App\Livewire\Server\Advanced as ServerAdvanced;
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow; use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
use App\Livewire\Server\Charts as ServerCharts; use App\Livewire\Server\Charts as ServerCharts;
use App\Livewire\Server\CloudflareTunnel; use App\Livewire\Server\CloudflareTunnel;
use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow;
use App\Livewire\Server\Delete as DeleteServer; use App\Livewire\Server\Delete as DeleteServer;
use App\Livewire\Server\Destinations as ServerDestinations; use App\Livewire\Server\Destinations as ServerDestinations;
use App\Livewire\Server\DockerCleanup; use App\Livewire\Server\DockerCleanup;
@@ -248,6 +249,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/', ServerShow::class)->name('server.show'); Route::get('/', ServerShow::class)->name('server.show');
Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); Route::get('/advanced', ServerAdvanced::class)->name('server.advanced');
Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); 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('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate');
Route::get('/resources', ResourcesShow::class)->name('server.resources'); Route::get('/resources', ResourcesShow::class)->name('server.resources');
Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel');