clusev/app/Livewire/Servers/Groups.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());
}
}