321 lines
11 KiB
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(),
|
|
]);
|
|
}
|
|
}
|