172 lines
5.5 KiB
PHP
172 lines
5.5 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Settings;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\User;
|
|
use App\Services\SessionService;
|
|
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',
|
|
'auditTarget' => $user->email,
|
|
'event' => 'userRemoved',
|
|
'params' => ['userId' => $userId],
|
|
// Defer the toast: the apply handler reports the real outcome.
|
|
'notify' => '',
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('userRemoved')]
|
|
public function remove(int $userId, SessionService $sessions): void
|
|
{
|
|
$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',
|
|
'auditTarget' => $user->email,
|
|
'event' => 'userLoggedOut',
|
|
'params' => ['userId' => $userId],
|
|
'notify' => '',
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('userLoggedOut')]
|
|
public function logout(int $userId, SessionService $sessions): void
|
|
{
|
|
$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(),
|
|
]);
|
|
}
|
|
}
|