dispatch('openModal', component: 'modals.create-user'); } /** * Destructive (R5): open the confirm modal for removing an account. Guards * (self / last account) are enforced here AND again in the apply handler, so * a stale event can never slip past after the modal is open. */ public function confirmRemove(int $userId): void { $user = User::find($userId); if (! $user || ! $this->canRemove($user)) { return; } $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('accounts.remove_heading'), 'body' => __('accounts.remove_body'), 'confirmLabel' => __('accounts.remove'), 'danger' => true, 'icon' => 'trash', // Defer the toast: the apply handler reports the real outcome. 'notify' => '', 'token' => ConfirmToken::issue( 'userRemoved', ['userId' => $userId], auditTarget: $user->email, ), ], ); } #[On('userRemoved')] public function remove(string $confirmToken, SessionService $sessions): void { try { $payload = ConfirmToken::consume($confirmToken, 'userRemoved'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } $userId = $payload['params']['userId']; $self = Auth::id(); // Atomic: lock the user rows + recount inside the transaction so two concurrent // deletes can't both pass the "more than one account" check and strand the // install with zero users (a lock-out). The last account is never removable. $email = DB::transaction(function () use ($userId, $self, $sessions): ?string { if (User::lockForUpdate()->count() <= 1) { return null; } $user = User::find($userId); if (! $user || $user->getKey() === $self) { return null; } $email = $user->email; // Kill every session + rotate the remember_token BEFORE deleting, so a // stolen remember-me cookie can never resurrect the account between the // session wipe and the row delete. $sessions->logoutUserEverywhere($user); $user->delete(); return $email; }); if ($email === null) { return; } $this->audit('user.delete', $email); $this->dispatch('notify', message: __('accounts.remove_notify')); } /** * Destructive (R5): open the confirm modal for signing another account out * of every device. Never offered for self (self uses Settings → Sessions). */ public function confirmLogout(int $userId): void { $user = User::find($userId); if (! $user || $user->is(Auth::user())) { return; } $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('accounts.logout_heading'), 'body' => __('accounts.logout_body'), 'confirmLabel' => __('accounts.logout'), 'danger' => true, 'icon' => 'logout', 'notify' => '', 'token' => ConfirmToken::issue( 'userLoggedOut', ['userId' => $userId], auditTarget: $user->email, ), ], ); } #[On('userLoggedOut')] public function logout(string $confirmToken, SessionService $sessions): void { try { $payload = ConfirmToken::consume($confirmToken, 'userLoggedOut'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } $userId = $payload['params']['userId']; $user = User::find($userId); if (! $user || $user->is(Auth::user())) { return; } $sessions->logoutUserEverywhere($user); $this->audit('user.logout', $user->email); $this->dispatch('notify', message: __('accounts.logout_notify')); } /** Reload the list after the CreateUser modal adds an account. */ #[On('usersChanged')] public function refreshList(): void { // The list is re-fetched in render(); this handler just forces the round trip. } /** * A user may be removed unless it is the current operator or the last * remaining account (no lock-out). Mirrors the create-user "equal admins" * model — anyone can remove anyone else. */ private function canRemove(User $user): bool { return ! $user->is(Auth::user()) && User::count() > 1; } 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 render() { return view('livewire.settings.users', [ 'users' => User::query()->orderBy('name')->get(), 'currentId' => Auth::id(), ]); } }