clusev/app/Livewire/Settings/Users.php

192 lines
6.2 KiB
PHP

<?php
namespace App\Livewire\Settings;
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. Every account is a full administrator — there are
* no roles. Destructive actions (remove, force-logout) go through the R5 confirm
* modal, which writes one AuditEvent and re-dispatches the apply event back here
* (mirrors Settings\Security / Settings\Sessions).
*
* 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
{
$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
{
$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(),
]);
}
}