160 lines
5.3 KiB
PHP
160 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Servers;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Server;
|
|
use App\Models\ServerGroup;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Manage server groups (create / rename / recolour / delete + membership). Admin-only
|
|
* (manage-fleet, matching server create/delete): the route mw gates it AND every mutating
|
|
* method re-checks, since /livewire/update does not re-run route middleware. Every change is
|
|
* audited; deletion goes through the signed, single-use ConfirmToken + the R5 confirm modal.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Groups extends Component
|
|
{
|
|
/** New-group form. */
|
|
public string $name = '';
|
|
|
|
public string $color = 'accent';
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('manage-fleet'), 403);
|
|
}
|
|
|
|
public function title(): string
|
|
{
|
|
return __('groups.title');
|
|
}
|
|
|
|
private function gate(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('manage-fleet'), 403);
|
|
}
|
|
|
|
private function audit(string $action, string $target): void
|
|
{
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => $action,
|
|
'target' => $target,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
public function create(): void
|
|
{
|
|
$this->gate();
|
|
|
|
$data = $this->validate([
|
|
'name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')],
|
|
'color' => ['required', Rule::in(ServerGroup::COLORS)],
|
|
]);
|
|
|
|
$group = ServerGroup::create($data);
|
|
$this->audit('group.create', $group->name);
|
|
$this->reset('name');
|
|
$this->color = 'accent';
|
|
$this->dispatch('notify', message: __('groups.created', ['name' => $group->name]));
|
|
}
|
|
|
|
public function rename(string $uuid, string $name): void
|
|
{
|
|
$this->gate();
|
|
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
|
|
|
|
$name = trim($name);
|
|
$validated = validator(
|
|
['name' => $name],
|
|
['name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')->ignore($group->id)]],
|
|
)->validate();
|
|
|
|
$group->update(['name' => $validated['name']]);
|
|
$this->audit('group.update', $group->name);
|
|
}
|
|
|
|
public function setColor(string $uuid, string $color): void
|
|
{
|
|
$this->gate();
|
|
if (! in_array($color, ServerGroup::COLORS, true)) {
|
|
return; // saving() would coerce it anyway; refuse the bad token outright
|
|
}
|
|
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
|
|
$group->update(['color' => $color]);
|
|
$this->audit('group.update', $group->name);
|
|
}
|
|
|
|
/** Add or remove a server from a group (checkbox toggle in the assignment grid). */
|
|
public function toggleMember(string $groupUuid, string $serverUuid): void
|
|
{
|
|
$this->gate();
|
|
$group = ServerGroup::where('uuid', $groupUuid)->firstOrFail();
|
|
$server = Server::where('uuid', $serverUuid)->firstOrFail();
|
|
|
|
$group->servers()->toggle($server->id);
|
|
$this->audit('group.members', $group->name);
|
|
}
|
|
|
|
public function confirmDelete(string $uuid): void
|
|
{
|
|
$this->gate();
|
|
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('groups.delete_heading'),
|
|
'body' => __('groups.delete_body', ['name' => $group->name]),
|
|
'confirmLabel' => __('common.delete'),
|
|
'danger' => true,
|
|
'icon' => 'trash',
|
|
'notify' => __('groups.deleted', ['name' => $group->name]),
|
|
// The confirm modal writes the group.delete AuditEvent from this sealed descriptor,
|
|
// so deleteGroup() must NOT audit again (avoids a double row).
|
|
'token' => ConfirmToken::issue('groupConfirmed', ['uuid' => $group->uuid], 'group.delete', $group->name, null),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('groupConfirmed')]
|
|
public function deleteGroup(string $confirmToken): void
|
|
{
|
|
$this->gate();
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'groupConfirmed');
|
|
} catch (InvalidConfirmToken) {
|
|
return; // forged / replayed / direct-bypass attempt — no-op
|
|
}
|
|
|
|
$group = ServerGroup::where('uuid', $payload['params']['uuid'])->first();
|
|
if (! $group) {
|
|
return;
|
|
}
|
|
|
|
// NOT audited here — the confirm modal already wrote group.delete from the sealed token.
|
|
$group->delete(); // pivot rows cascade; servers are untouched
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.servers.groups', [
|
|
'groups' => ServerGroup::withCount('servers')->with('servers:id,uuid')->orderBy('name')->get(),
|
|
'servers' => Server::orderBy('name')->get(['id', 'uuid', 'name']),
|
|
'colors' => ServerGroup::COLORS,
|
|
])->title($this->title());
|
|
}
|
|
}
|