feat(rbac): user-management roles UI — role badge, selector, role-change with last-admin guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
b529201186
commit
61b8419106
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -12,7 +13,8 @@ use Illuminate\Validation\Rules\Password;
|
|||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Form modal: add a new administrator account. Every account is a full admin (no roles). The creator
|
||||
* Form modal: add a new account with an RBAC role (admin > operator > viewer; new accounts
|
||||
* default to `operator` — least privilege). Restricted to admins (`manage-users`). The creator
|
||||
* may set a password directly, or leave it blank — then a strong initial password is generated, the
|
||||
* account is flagged `must_change_password`, and the clear-text password is revealed ONCE in the modal
|
||||
* (never persisted or logged). An operator-set password is the user's own, so no forced rotation.
|
||||
|
|
@ -23,6 +25,9 @@ class CreateUser extends ModalComponent
|
|||
|
||||
public string $email = '';
|
||||
|
||||
/** New account role (admin > operator > viewer). Defaults to operator — least privilege. */
|
||||
public string $role = 'operator';
|
||||
|
||||
/** Optional operator-set password; blank = generate an initial password and reveal it once. */
|
||||
public string $password = '';
|
||||
|
||||
|
|
@ -32,6 +37,11 @@ class CreateUser extends ModalComponent
|
|||
/** Name of the account just created, used in the reveal copy. */
|
||||
public string $createdName = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
|
|
@ -47,6 +57,7 @@ class CreateUser extends ModalComponent
|
|||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
// Optional — when set it must meet the same strength as a self-chosen password.
|
||||
'password' => ['nullable', 'string', Password::min(6)],
|
||||
'role' => ['required', Rule::enum(Role::class)],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -59,11 +70,14 @@ class CreateUser extends ModalComponent
|
|||
'name' => __('accounts.name_label'),
|
||||
'email' => __('accounts.email_label'),
|
||||
'password' => __('accounts.password_label'),
|
||||
'role' => __('accounts.role_label'),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$data = $this->validate();
|
||||
|
||||
// Re-check uniqueness server-side regardless of the live validator state.
|
||||
|
|
@ -84,6 +98,7 @@ class CreateUser extends ModalComponent
|
|||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
'must_change_password' => ! $operatorSet,
|
||||
'role' => $data['role'],
|
||||
]);
|
||||
|
||||
AuditEvent::create([
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\SessionService;
|
||||
|
|
@ -13,11 +14,15 @@ 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
|
||||
* 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
|
||||
|
|
@ -25,6 +30,8 @@ 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');
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +42,8 @@ class Users extends Component
|
|||
*/
|
||||
public function confirmRemove(int $userId): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$user = User::find($userId);
|
||||
if (! $user || ! $this->canRemove($user)) {
|
||||
return;
|
||||
|
|
@ -62,6 +71,8 @@ class Users extends Component
|
|||
#[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) {
|
||||
|
|
@ -74,6 +85,8 @@ class Users extends Component
|
|||
// 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;
|
||||
|
|
@ -84,6 +97,14 @@ class Users extends Component
|
|||
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
|
||||
|
|
@ -99,6 +120,12 @@ class Users extends Component
|
|||
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'));
|
||||
}
|
||||
|
|
@ -109,6 +136,8 @@ class Users extends Component
|
|||
*/
|
||||
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;
|
||||
|
|
@ -135,6 +164,8 @@ class Users extends Component
|
|||
#[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) {
|
||||
|
|
@ -153,6 +184,103 @@ class Users extends Component
|
|||
$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
|
||||
|
|
@ -161,9 +289,10 @@ class Users extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@ use JsonException;
|
|||
* - the token is bound to the issuing operator (uid), so it cannot be replayed by
|
||||
* another account.
|
||||
*
|
||||
* All accounts are equal admins (no RBAC); this is an integrity/confirmation control,
|
||||
* not an authorization tier. Server-scoped actions additionally seal the server id —
|
||||
* the handler must match it against the server it is about to act on.
|
||||
* RBAC now exists (admin > operator > viewer); the mutating actions guard the
|
||||
* `manage-users`/`operate`/etc. abilities themselves. ConfirmToken remains an orthogonal
|
||||
* integrity/confirmation control — it proves a human passed through the modal and seals the
|
||||
* routing/audit descriptor, it does NOT decide authorization. Server-scoped actions
|
||||
* additionally seal the server id — the handler must match it against the server it acts on.
|
||||
*/
|
||||
class ConfirmToken
|
||||
{
|
||||
|
|
@ -43,6 +45,7 @@ class ConfirmToken
|
|||
'serviceConfirmed',
|
||||
'userRemoved',
|
||||
'userLoggedOut',
|
||||
'userRoleChanged',
|
||||
'domainChanged',
|
||||
'tlsModeChanged',
|
||||
'fileConfirmed',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<?php
|
||||
|
||||
// Multi-user account management (Settings → Users). Alle Konten sind gleichberechtigte
|
||||
// Administratoren (keine Rollen). Eine Gruppe pro Feature (R16); geteilte Buttons in common.php.
|
||||
// Multi-user account management (Settings → Users). Konten haben jetzt Rollen
|
||||
// (Administrator > Operator > Betrachter). Eine Gruppe pro Feature (R16); geteilte Buttons in common.php.
|
||||
return [
|
||||
// Panel header
|
||||
'title' => 'Konten',
|
||||
'subtitle' => 'Jedes Konto ist vollwertiger Administrator',
|
||||
'subtitle' => 'Konten mit Rollen: Administrator, Operator, Betrachter',
|
||||
'create' => 'Konto hinzufügen',
|
||||
|
||||
// List
|
||||
|
|
@ -14,7 +14,7 @@ return [
|
|||
|
||||
// Create modal
|
||||
'create_title' => 'Konto hinzufügen',
|
||||
'create_subtitle' => 'Vollwertiger Administrator. Setze ein Passwort — oder lass es leer, dann wird ein Initialpasswort erzeugt und einmalig angezeigt.',
|
||||
'create_subtitle' => 'Wähle eine Rolle. Setze ein Passwort — oder lass es leer, dann wird ein Initialpasswort erzeugt und einmalig angezeigt.',
|
||||
'name_label' => 'Name',
|
||||
'name_placeholder' => 'Erika Mustermann',
|
||||
'email_label' => 'E-Mail',
|
||||
|
|
@ -25,6 +25,19 @@ return [
|
|||
'created_notify' => 'Konto :name angelegt.',
|
||||
'create_submit' => 'Konto anlegen',
|
||||
|
||||
// Role (badge, selector, hint)
|
||||
'role_label' => 'Rolle',
|
||||
'role_hint' => 'Administratoren steuern das gesamte Panel; Operatoren führen den Betrieb; Betrachter sehen nur.',
|
||||
|
||||
// Role change (R5 confirm)
|
||||
'role_change_confirm' => 'Rolle ändern',
|
||||
'role_change_confirm_heading' => 'Rolle ändern',
|
||||
'role_change_confirm_body' => 'Setzt die Rolle von :name auf :role. Berechtigungen ändern sich sofort.',
|
||||
'role_changed_toast' => 'Rolle geändert.',
|
||||
|
||||
// Last-admin invariant
|
||||
'last_admin_guard' => 'Der letzte Administrator kann nicht herabgestuft oder entfernt werden.',
|
||||
|
||||
// Temp-password reveal (shown once)
|
||||
'temp_heading' => 'Konto angelegt',
|
||||
'temp_intro' => 'Gib dieses Initialpasswort an :name weiter. Es sollte beim ersten Anmelden geändert werden.',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ return [
|
|||
'user.create' => 'Benutzer angelegt',
|
||||
'user.delete' => 'Benutzer gelöscht',
|
||||
'user.logout' => 'Sitzung beendet',
|
||||
'user.role_change' => 'Rolle geändert',
|
||||
'webauthn.register' => 'Sicherheitsschlüssel registriert',
|
||||
'webauthn.remove' => 'Sicherheitsschlüssel entfernt',
|
||||
'wg.setup' => 'WireGuard eingerichtet',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<?php
|
||||
|
||||
// Multi-user account management (Settings → Users). All accounts are equal admins
|
||||
// (no roles). One group per feature (R16); shared buttons live in common.php.
|
||||
// Multi-user account management (Settings → Users). Accounts now carry roles
|
||||
// (admin > operator > viewer). One group per feature (R16); shared buttons live in common.php.
|
||||
return [
|
||||
// Panel header
|
||||
'title' => 'Accounts',
|
||||
'subtitle' => 'Every account is a full administrator',
|
||||
'subtitle' => 'Accounts with roles: admin, operator, viewer',
|
||||
'create' => 'Add account',
|
||||
|
||||
// List
|
||||
|
|
@ -14,7 +14,7 @@ return [
|
|||
|
||||
// Create modal
|
||||
'create_title' => 'Add account',
|
||||
'create_subtitle' => 'Full administrator. Set a password — or leave it blank to generate a initial password shown once.',
|
||||
'create_subtitle' => 'Pick a role. Set a password — or leave it blank to generate an initial password shown once.',
|
||||
'name_label' => 'Name',
|
||||
'name_placeholder' => 'Jane Doe',
|
||||
'email_label' => 'Email',
|
||||
|
|
@ -25,6 +25,19 @@ return [
|
|||
'created_notify' => 'Account :name created.',
|
||||
'create_submit' => 'Create account',
|
||||
|
||||
// Role (badge, selector, hint)
|
||||
'role_label' => 'Role',
|
||||
'role_hint' => 'Admins control the whole panel; operators run operations; viewers only look.',
|
||||
|
||||
// Role change (R5 confirm)
|
||||
'role_change_confirm' => 'Change role',
|
||||
'role_change_confirm_heading' => 'Change role',
|
||||
'role_change_confirm_body' => 'Sets :name’s role to :role. Permissions change immediately.',
|
||||
'role_changed_toast' => 'Role changed.',
|
||||
|
||||
// Last-admin invariant
|
||||
'last_admin_guard' => 'The last administrator cannot be demoted or removed.',
|
||||
|
||||
// Temp-password reveal (shown once)
|
||||
'temp_heading' => 'Account created',
|
||||
'temp_intro' => 'Share this initial password with :name. They are prompted to change it on first sign-in.',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ return [
|
|||
'user.create' => 'User created',
|
||||
'user.delete' => 'User deleted',
|
||||
'user.logout' => 'Session ended',
|
||||
'user.role_change' => 'Role changed',
|
||||
'webauthn.register' => 'Security key registered',
|
||||
'webauthn.remove' => 'Security key removed',
|
||||
'wg.setup' => 'WireGuard set up',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@
|
|||
@error('email')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('accounts.role_label') }}</label>
|
||||
<select wire:model="role"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none">
|
||||
<option value="{{ \App\Enums\Role::Admin->value }}">{{ __('roles.role_admin') }}</option>
|
||||
<option value="{{ \App\Enums\Role::Operator->value }}">{{ __('roles.role_operator') }}</option>
|
||||
<option value="{{ \App\Enums\Role::Viewer->value }}">{{ __('roles.role_viewer') }}</option>
|
||||
</select>
|
||||
@error('role')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
<p class="mt-1 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('accounts.role_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div x-data="{ show: false }">
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('accounts.password_label') }}</label>
|
||||
<div class="relative">
|
||||
|
|
|
|||
|
|
@ -24,22 +24,39 @@
|
|||
@if ($isSelf)
|
||||
<x-badge tone="accent">{{ __('accounts.you') }}</x-badge>
|
||||
@endif
|
||||
<x-badge :tone="$user->role === \App\Enums\Role::Admin ? 'accent' : 'neutral'">{{ $user->role->label() }}</x-badge>
|
||||
<x-two-factor-badge :enabled="$user->hasTwoFactorEnabled()" />
|
||||
</div>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $user->email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
@can('manage-users')
|
||||
{{-- Role selector. For self it is disabled: you cannot change your own role
|
||||
(and the last-admin guard would refuse a self-demotion anyway). The
|
||||
server-side confirmRoleChange + last-admin guard is the real protection. --}}
|
||||
<label class="sr-only" for="role-{{ $user->getKey() }}">{{ __('accounts.role_label') }}</label>
|
||||
<select id="role-{{ $user->getKey() }}"
|
||||
@disabled($isSelf)
|
||||
wire:key="role-{{ $user->getKey() }}"
|
||||
wire:change="confirmRoleChange({{ $user->getKey() }}, $event.target.value)"
|
||||
class="h-8 rounded-md border border-line bg-inset px-2 font-mono text-[11px] text-ink focus:border-accent/40 focus:outline-none disabled:opacity-60">
|
||||
@foreach (\App\Enums\Role::cases() as $role)
|
||||
<option value="{{ $role->value }}" @selected($user->role === $role)>{{ $role->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@endcan
|
||||
|
||||
@unless ($isSelf)
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<x-modal-trigger variant="secondary" action="confirmLogout({{ $user->getKey() }})">
|
||||
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('accounts.logout') }}
|
||||
</x-modal-trigger>
|
||||
<x-modal-trigger variant="danger-soft" action="confirmRemove({{ $user->getKey() }})">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('accounts.remove') }}
|
||||
</x-modal-trigger>
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('accounts.none') }}</p>
|
||||
@endforelse
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Livewire\Modals\CreateUser;
|
||||
use App\Livewire\Settings\Users;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* RBAC on the account-management surface: the mutating Settings\Users actions and the
|
||||
* CreateUser modal are admin-only (`manage-users`), role changes go through the R5 confirm
|
||||
* flow, and the last-admin invariant is never violated.
|
||||
*/
|
||||
class RbacUsersTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Issue + confirm a token so the apply handler can consume it (mirrors the modal). */
|
||||
private function confirmedToken(string $event, array $params): string
|
||||
{
|
||||
$token = ConfirmToken::issue($event, $params);
|
||||
ConfirmToken::confirm($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
// ── Gate: non-admins are forbidden, admins allowed ───────────────────────────────
|
||||
|
||||
public function test_non_admin_cannot_open_create(): void
|
||||
{
|
||||
foreach (['operator', 'viewer'] as $state) {
|
||||
$user = User::factory()->{$state}()->create();
|
||||
|
||||
Livewire::actingAs($user)->test(Users::class)
|
||||
->call('create')
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_confirm_remove(): void
|
||||
{
|
||||
$operator = User::factory()->operator()->create();
|
||||
$victim = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($operator)->test(Users::class)
|
||||
->call('confirmRemove', $victim->id)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_confirm_role_change(): void
|
||||
{
|
||||
$viewer = User::factory()->viewer()->create();
|
||||
$target = User::factory()->operator()->create();
|
||||
|
||||
Livewire::actingAs($viewer)->test(Users::class)
|
||||
->call('confirmRoleChange', $target->id, 'viewer')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_may_open_create_and_confirm_role_change(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$target = User::factory()->operator()->create();
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('create')
|
||||
->assertOk()
|
||||
->assertDispatched('openModal');
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('confirmRoleChange', $target->id, 'viewer')
|
||||
->assertOk()
|
||||
->assertDispatched('openModal');
|
||||
}
|
||||
|
||||
// ── applyRoleChange: happy paths ─────────────────────────────────────────────────
|
||||
|
||||
public function test_admin_demotes_operator_to_viewer_and_audits(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$target = User::factory()->operator()->create(['email' => 'op@example.com']);
|
||||
|
||||
$this->actingAs($admin);
|
||||
$token = $this->confirmedToken('userRoleChanged', ['userId' => $target->id, 'role' => 'viewer']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('applyRoleChange', $token)
|
||||
->assertDispatched('usersChanged');
|
||||
|
||||
$this->assertSame(Role::Viewer, $target->fresh()->role);
|
||||
$this->assertDatabaseHas('audit_events', [
|
||||
'action' => 'user.role_change',
|
||||
'target' => 'op@example.com',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_promotes_operator_to_admin(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$target = User::factory()->operator()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
$token = $this->confirmedToken('userRoleChanged', ['userId' => $target->id, 'role' => 'admin']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('applyRoleChange', $token);
|
||||
|
||||
$this->assertSame(Role::Admin, $target->fresh()->role);
|
||||
}
|
||||
|
||||
// ── Last-admin invariant ─────────────────────────────────────────────────────────
|
||||
|
||||
public function test_sole_admin_cannot_be_demoted(): void
|
||||
{
|
||||
// Exactly one admin (self); an operator keeps the account count > 1.
|
||||
$admin = User::factory()->create();
|
||||
User::factory()->operator()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
$token = $this->confirmedToken('userRoleChanged', ['userId' => $admin->id, 'role' => 'viewer']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('applyRoleChange', $token)
|
||||
->assertNotDispatched('usersChanged');
|
||||
|
||||
// Role unchanged — the sole admin stays admin, no exception thrown.
|
||||
$this->assertSame(Role::Admin, $admin->fresh()->role);
|
||||
$this->assertSame(0, AuditEvent::where('action', 'user.role_change')->count());
|
||||
}
|
||||
|
||||
public function test_one_of_two_admins_may_be_demoted(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$other = User::factory()->create(); // second admin (factory default role)
|
||||
|
||||
$this->actingAs($admin);
|
||||
$token = $this->confirmedToken('userRoleChanged', ['userId' => $other->id, 'role' => 'operator']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('applyRoleChange', $token)
|
||||
->assertDispatched('usersChanged');
|
||||
|
||||
$this->assertSame(Role::Operator, $other->fresh()->role);
|
||||
}
|
||||
|
||||
public function test_removing_the_sole_admin_is_refused_and_admin_survives(): void
|
||||
{
|
||||
// Two accounts so the last-ACCOUNT guard passes, but only one admin (self). The
|
||||
// acting admin removing itself is caught by the self-guard first; the last-admin
|
||||
// removal guard is the defense-in-depth behind it. Either way: the admin survives.
|
||||
$admin = User::factory()->create();
|
||||
User::factory()->operator()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
$token = $this->confirmedToken('userRemoved', ['userId' => $admin->id]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('remove', $token);
|
||||
|
||||
$this->assertDatabaseHas('users', ['id' => $admin->id]);
|
||||
$this->assertSame(Role::Admin, $admin->fresh()->role);
|
||||
}
|
||||
|
||||
// ── CreateUser modal: admin-only, default role, validation ───────────────────────
|
||||
|
||||
public function test_non_admin_cannot_mount_create_user(): void
|
||||
{
|
||||
$operator = User::factory()->operator()->create();
|
||||
|
||||
Livewire::actingAs($operator)->test(CreateUser::class)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_creates_user_defaulting_to_operator(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($admin)->test(CreateUser::class)
|
||||
->set('name', 'New Op')
|
||||
->set('email', 'newop@example.com')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$new = User::where('email', 'newop@example.com')->first();
|
||||
$this->assertNotNull($new);
|
||||
$this->assertSame(Role::Operator, $new->role);
|
||||
}
|
||||
|
||||
public function test_create_user_role_validation_rejects_invalid_role(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($admin)->test(CreateUser::class)
|
||||
->set('name', 'Bad Role')
|
||||
->set('email', 'badrole@example.com')
|
||||
->set('role', 'superuser')
|
||||
->call('save')
|
||||
->assertHasErrors('role');
|
||||
|
||||
$this->assertNull(User::where('email', 'badrole@example.com')->first());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue