operator > * viewer); the mutating actions here are gated behind the `manage-users` ability (admin * only). Destructive actions (remove, force-logout) and role changes go through the R5 * confirm modal, which writes one AuditEvent and re-dispatches the apply event back here * (mirrors Settings\Security / Settings\Sessions). * * The last-admin invariant is enforced everywhere: the sole remaining admin can neither * be removed nor demoted, so the panel can never be stranded with zero administrators. * * The current operator is always recorded as the actor on each audit row. */ class Users extends Component { /** Open the CreateUser modal; it dispatches `usersChanged` on success. */ public function create(): void { abort_unless(auth()->user()?->can('manage-users'), 403); $this->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 { abort_unless(auth()->user()?->can('manage-users'), 403); $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 { abort_unless(auth()->user()?->can('manage-users'), 403); 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. // Sentinel: null = silent no-op (self / last account / gone); 'last_admin' = // refused because it would strand zero admins → surface the guard toast. $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; } // Last-admin invariant: removing the sole administrator would leave the panel // with zero admins (no one can manage users again). Lock + recount admins in // the same transaction so concurrent removes can't both slip past the check. if ($user->role === Role::Admin && User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) { return 'last_admin'; } $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; } if ($email === 'last_admin') { $this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error'); 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 { abort_unless(auth()->user()?->can('manage-users'), 403); $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 { abort_unless(auth()->user()?->can('manage-users'), 403); 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')); } /** * Sensitive (R5): open the confirm modal for changing another account's role. The * new role must be a valid Role case; the apply handler re-checks the last-admin * invariant so a stale event can never demote the sole admin after the modal opens. */ public function confirmRoleChange(int $userId, string $role): void { abort_unless(auth()->user()?->can('manage-users'), 403); $user = User::find($userId); if (! $user || ! in_array($role, array_column(Role::cases(), 'value'), true)) { return; } $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('accounts.role_change_confirm_heading'), 'body' => __('accounts.role_change_confirm_body', [ 'name' => $user->name, 'role' => Role::from($role)->label(), ]), 'confirmLabel' => __('accounts.role_change_confirm'), 'danger' => false, 'icon' => 'shield', // Defer the toast: the apply handler reports the real outcome. 'notify' => '', 'token' => ConfirmToken::issue( 'userRoleChanged', ['userId' => $userId, 'role' => $role], auditTarget: $user->email, ), ], ); } #[On('userRoleChanged')] public function applyRoleChange(string $confirmToken): void { abort_unless(auth()->user()?->can('manage-users'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'userRoleChanged'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } $userId = $payload['params']['userId']; $role = $payload['params']['role']; // Atomic: lock the target + recount admins inside the transaction so two concurrent // demotions can't both pass the last-admin check and strand zero administrators. // Sentinel: null = silent no-op (gone / same role); 'last_admin' = refused because // it would demote the sole admin → surface the guard toast. $result = DB::transaction(function () use ($userId, $role): ?string { $user = User::whereKey($userId)->lockForUpdate()->first(); if (! $user) { return null; } $newRole = Role::from($role); // Last-admin invariant: never demote the sole administrator away from admin. if ($user->role === Role::Admin && $newRole !== Role::Admin && User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) { return 'last_admin'; } $user->role = $newRole; $user->save(); AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name, 'action' => 'user.role_change', 'target' => $user->email, 'ip' => request()->ip(), 'meta' => ['to' => $role], ]); return $user->email; }); if ($result === null) { return; } if ($result === 'last_admin') { $this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error'); return; } $this->dispatch('usersChanged'); $this->dispatch('notify', message: __('accounts.role_changed_toast')); } /** 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 offered the remove control unless it is the current operator or the * last remaining account (no lock-out). This is only the UI gate; the removal itself * is restricted to admins (`manage-users`) and the apply handler additionally refuses * to strand zero administrators (last-admin invariant). */ 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(), ]); } }