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
boban 2026-07-05 01:15:32 +02:00
parent b529201186
commit 61b8419106
10 changed files with 435 additions and 22 deletions

View File

@ -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([

View File

@ -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
{

View File

@ -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',

View File

@ -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.',

View File

@ -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',

View File

@ -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 :names 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.',

View File

@ -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',

View File

@ -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">

View File

@ -24,21 +24,38 @@
@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>
@unless ($isSelf)
<div class="flex shrink-0 items-center gap-2">
<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)
<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
@endunless
</div>
</div>
@empty
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('accounts.none') }}</p>

View File

@ -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());
}
}