clusev/app/Livewire/Settings/Users.php

321 lines
11 KiB
PHP

<?php
namespace App\Livewire\Settings;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Multi-user account management. Accounts now carry an RBAC role (admin > 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(),
]);
}
}