feat: add cloud-init scripts management UI in Security section

Add comprehensive cloud-init script management interface in the Security
section, allowing users to create, edit, delete, and reuse cloud-init
scripts across their team.

New Components:
- CloudInitScripts: Main listing page with grid view of scripts
- CloudInitScriptForm: Modal form for create/edit operations

Features:
- Create new cloud-init scripts with name and content
- Edit existing scripts
- Delete scripts with confirmation (requires typing script name)
- View script preview (first 200 characters)
- Scripts are encrypted in database
- Full authorization using CloudInitScriptPolicy
- Real-time updates via Livewire events

UI Location:
- Added to Security section nav: /security/cloud-init-scripts
- Positioned between Cloud Tokens and API Tokens
- Follows existing security UI patterns

Files Created:
- app/Livewire/Security/CloudInitScripts.php
- app/Livewire/Security/CloudInitScriptForm.php
- resources/views/livewire/security/cloud-init-scripts.blade.php
- resources/views/livewire/security/cloud-init-script-form.blade.php

Files Modified:
- routes/web.php - Added route
- resources/views/components/security/navbar.blade.php - Added nav link

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-10-11 12:42:09 +02:00
parent b31b080799
commit 5463f4d496
6 changed files with 230 additions and 0 deletions

View file

@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScriptForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = true;
public ?int $scriptId = null;
public string $name = '';
public string $script = '';
public function mount(?int $scriptId = null)
{
if ($scriptId) {
$this->scriptId = $scriptId;
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('update', $cloudInitScript);
$this->name = $cloudInitScript->name;
$this->script = $cloudInitScript->script;
} else {
$this->authorize('create', CloudInitScript::class);
}
}
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => 'required|string',
];
}
protected function messages(): array
{
return [
'name.required' => 'Script name is required.',
'name.max' => 'Script name cannot exceed 255 characters.',
'script.required' => 'Cloud-init script content is required.',
];
}
public function save()
{
$this->validate();
try {
if ($this->scriptId) {
// Update existing script
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId);
$this->authorize('update', $cloudInitScript);
$cloudInitScript->update([
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script updated successfully.';
} else {
// Create new script
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script created successfully.';
}
$this->reset(['name', 'script', 'scriptId']);
$this->dispatch('scriptSaved');
$this->dispatch('success', $message);
if ($this->modal_mode) {
$this->dispatch('closeModal');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-script-form');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScripts extends Component
{
use AuthorizesRequests;
public $scripts;
public function mount()
{
$this->authorize('viewAny', CloudInitScript::class);
$this->loadScripts();
}
public function getListeners()
{
return [
'scriptSaved' => 'loadScripts',
];
}
public function loadScripts()
{
$this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
}
public function deleteScript(int $scriptId)
{
try {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('delete', $script);
$script->delete();
$this->loadScripts();
$this->dispatch('success', 'Cloud-init script deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-scripts');
}
}

View file

@ -11,6 +11,11 @@
<button>Cloud Tokens</button>
</a>
@endcan
@can('viewAny', App\Models\CloudInitScript::class)
<a href="{{ route('security.cloud-init-scripts') }}">
<button>Cloud-Init Scripts</button>
</a>
@endcan
<a href="{{ route('security.api-tokens') }}">
<button>API Tokens</button>
</a>

View file

@ -0,0 +1,17 @@
<form wire:submit='save' class="flex flex-col gap-4">
<x-forms.input id="name" label="Script Name" helper="A descriptive name for this cloud-init script." required />
<x-forms.textarea id="script" label="Script Content" rows="12"
helper="Enter your cloud-init script. Supports both bash scripts and cloud-config YAML format." required />
<div class="flex justify-end gap-2">
@if ($modal_mode)
<x-forms.button type="button" @click="$dispatch('closeModal')">
Cancel
</x-forms.button>
@endif
<x-forms.button type="submit" isHighlighted>
{{ $scriptId ? 'Update Script' : 'Create Script' }}
</x-forms.button>
</div>
</form>

View file

@ -0,0 +1,57 @@
<div>
<x-security.navbar />
<div class="flex gap-2">
<h2 class="pb-4">Cloud-Init Scripts</h2>
@can('create', App\Models\CloudInitScript::class)
<x-modal-input buttonTitle="+ Add" title="New Cloud-Init Script">
<livewire:security.cloud-init-script-form />
</x-modal-input>
@endcan
</div>
<div class="pb-4 text-sm">Manage reusable cloud-init scripts for server initialization.</div>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($scripts as $script)
<div wire:key="script-{{ $script->id }}" class="box group">
<div class="flex flex-col gap-2 mx-6">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="box-title">{{ $script->name }}</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Created {{ $script->created_at->diffForHumans() }}
</div>
</div>
</div>
<div class="mt-2">
<div class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">Script Preview:</div>
<pre
class="p-2 text-xs rounded bg-neutral-100 dark:bg-coolgray-100 overflow-x-auto max-h-32 overflow-y-auto">{{ Str::limit($script->script, 200) }}</pre>
</div>
<div class="flex gap-2 mt-2">
@can('update', $script)
<x-modal-input buttonTitle="Edit" title="Edit Cloud-Init Script">
<livewire:security.cloud-init-script-form :scriptId="$script->id"
wire:key="edit-{{ $script->id }}" />
</x-modal-input>
@endcan
@can('delete', $script)
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
submitAction="deleteScript({{ $script->id }})" :actions="[
'This cloud-init script will be permanently deleted.',
'This action cannot be undone.',
]" confirmationText="{{ $script->name }}"
confirmationLabel="Please confirm the deletion by entering the script name below"
shortConfirmationLabel="Script Name" :confirmWithPassword="false"
step2ButtonText="Delete Script" />
@endcan
</div>
</div>
</div>
@empty
<div class="text-neutral-500">No cloud-init scripts found. Create one to get started.</div>
@endforelse
</div>
</div>

View file

@ -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\CloudInitScripts;
use App\Livewire\Security\CloudTokens;
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
@ -275,6 +276,7 @@
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/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts');
Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens');
});