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()); } }