feat(accounts): multi-user admin management — create (temp pw), list, remove, force-logout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
0cdc6c3a7b
commit
4ced37ac40
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Form modal: add a new administrator account. Every account is a full admin
|
||||
* (no roles). We generate a strong one-time password, store it hashed, and flag
|
||||
* the account `must_change_password` so the new admin must rotate it on first
|
||||
* sign-in. The clear-text temp password is revealed ONCE in the modal and never
|
||||
* persisted or logged.
|
||||
*/
|
||||
class CreateUser extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
/** Clear-text temp password, held in component state only to reveal it once. */
|
||||
public ?string $tempPassword = null;
|
||||
|
||||
/** Name of the account just created, used in the reveal copy. */
|
||||
public string $createdName = '';
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function validationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('accounts.name_label'),
|
||||
'email' => __('accounts.email_label'),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->validate();
|
||||
|
||||
// Re-check uniqueness server-side regardless of the live validator state.
|
||||
$email = mb_strtolower(trim($data['email']));
|
||||
if (User::where('email', $email)->exists()) {
|
||||
$this->addError('email', __('validation.unique', ['attribute' => __('accounts.email_label')]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$temp = Str::password(16);
|
||||
|
||||
$user = User::create([
|
||||
'name' => trim($data['name']),
|
||||
'email' => $email,
|
||||
'password' => Hash::make($temp),
|
||||
'must_change_password' => true,
|
||||
]);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'user.create',
|
||||
'target' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
// Reveal the temp password ONCE — swaps the form for the reveal panel.
|
||||
$this->createdName = $user->name;
|
||||
$this->tempPassword = $temp;
|
||||
}
|
||||
|
||||
/** "Fertig": reload the list and close. */
|
||||
public function finish(): void
|
||||
{
|
||||
$this->dispatch('usersChanged');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.create-user');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,152 @@
|
|||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\SessionService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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
|
||||
{
|
||||
$user = User::find($userId);
|
||||
if (! $user || ! $this->canRemove($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$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');
|
||||
return view('livewire.settings.users', [
|
||||
'users' => User::query()->orderBy('name')->get(),
|
||||
'currentId' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
// Multi-user account management (Settings → Users). Alle Konten sind gleichberechtigte
|
||||
// Administratoren (keine Rollen). Eine Gruppe pro Feature (R16); geteilte Buttons in common.php.
|
||||
return [
|
||||
// Panel header
|
||||
'title' => 'Konten',
|
||||
'subtitle' => 'Jedes Konto ist vollwertiger Administrator',
|
||||
'create' => 'Konto hinzufügen',
|
||||
|
||||
// List
|
||||
'you' => 'du',
|
||||
'twofa_on' => '2FA aktiv',
|
||||
'twofa_off' => '2FA aus',
|
||||
'none' => 'Keine Konten',
|
||||
|
||||
// Create modal
|
||||
'create_title' => 'Konto hinzufügen',
|
||||
'create_subtitle' => 'Das neue Konto ist vollwertiger Administrator und muss beim ersten Anmelden ein eigenes Passwort setzen.',
|
||||
'name_label' => 'Name',
|
||||
'name_placeholder' => 'Erika Mustermann',
|
||||
'email_label' => 'E-Mail',
|
||||
'email_placeholder' => 'erika@example.com',
|
||||
'create_submit' => 'Konto anlegen',
|
||||
|
||||
// Temp-password reveal (shown once)
|
||||
'temp_heading' => 'Konto angelegt',
|
||||
'temp_intro' => 'Gib dieses Einmal-Passwort an :name weiter. Es muss beim ersten Anmelden geändert werden.',
|
||||
'temp_label' => 'Temporäres Passwort',
|
||||
'temp_warning' => 'Kopiere dieses Passwort jetzt — es wird nur einmal angezeigt und kann später nicht erneut abgerufen werden.',
|
||||
'temp_copy' => 'Kopieren',
|
||||
'temp_copied' => 'Kopiert',
|
||||
'done' => 'Fertig',
|
||||
|
||||
// Remove (R5 confirm)
|
||||
'remove' => 'Entfernen',
|
||||
'remove_heading' => 'Konto entfernen',
|
||||
'remove_body' => 'Löscht das Konto dauerhaft und beendet alle seine Sitzungen. Das kann nicht rückgängig gemacht werden.',
|
||||
'remove_notify' => 'Konto entfernt.',
|
||||
'cannot_remove_self' => 'Du kannst dein eigenes Konto nicht entfernen.',
|
||||
'cannot_remove_last' => 'Du kannst das letzte verbleibende Konto nicht entfernen.',
|
||||
|
||||
// Force logout (R5 confirm)
|
||||
'logout' => 'Überall abmelden',
|
||||
'logout_heading' => 'Überall abmelden',
|
||||
'logout_body' => 'Beendet jede aktive Sitzung dieses Kontos und rotiert dessen Remember-Me-Token.',
|
||||
'logout_notify' => 'Konto von allen Geräten abgemeldet.',
|
||||
];
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?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.
|
||||
return [
|
||||
// Panel header
|
||||
'title' => 'Accounts',
|
||||
'subtitle' => 'Every account is a full administrator',
|
||||
'create' => 'Add account',
|
||||
|
||||
// List
|
||||
'you' => 'you',
|
||||
'twofa_on' => '2FA on',
|
||||
'twofa_off' => '2FA off',
|
||||
'none' => 'No accounts',
|
||||
|
||||
// Create modal
|
||||
'create_title' => 'Add account',
|
||||
'create_subtitle' => 'The new account is a full administrator and must set its own password on first sign-in.',
|
||||
'name_label' => 'Name',
|
||||
'name_placeholder' => 'Jane Doe',
|
||||
'email_label' => 'Email',
|
||||
'email_placeholder' => 'jane@example.com',
|
||||
'create_submit' => 'Create account',
|
||||
|
||||
// Temp-password reveal (shown once)
|
||||
'temp_heading' => 'Account created',
|
||||
'temp_intro' => 'Share this one-time password with :name. They must change it on first sign-in.',
|
||||
'temp_label' => 'Temporary password',
|
||||
'temp_warning' => 'Copy this password now — it is shown only once and cannot be retrieved later.',
|
||||
'temp_copy' => 'Copy',
|
||||
'temp_copied' => 'Copied',
|
||||
'done' => 'Done',
|
||||
|
||||
// Remove (R5 confirm)
|
||||
'remove' => 'Remove',
|
||||
'remove_heading' => 'Remove account',
|
||||
'remove_body' => 'This permanently deletes the account and ends all of its sessions. This cannot be undone.',
|
||||
'remove_notify' => 'Account removed.',
|
||||
'cannot_remove_self' => 'You cannot remove your own account.',
|
||||
'cannot_remove_last' => 'You cannot remove the last remaining account.',
|
||||
|
||||
// Force logout (R5 confirm)
|
||||
'logout' => 'Sign out everywhere',
|
||||
'logout_heading' => 'Sign out everywhere',
|
||||
'logout_body' => 'This ends every active session for this account and rotates its remember-me token.',
|
||||
'logout_notify' => 'Account signed out of all devices.',
|
||||
];
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
@if ($tempPassword === null)
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="user-plus" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ __('accounts.create_title') }}</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('accounts.create_subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('accounts.name_label') }}</label>
|
||||
<input wire:model="name" type="text" placeholder="{{ __('accounts.name_placeholder') }}" maxlength="120"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('name')<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.email_label') }}</label>
|
||||
<input wire:model="email" type="text" placeholder="{{ __('accounts.email_placeholder') }}" maxlength="255"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@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>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
|
||||
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<x-icon name="user-plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
{{ __('accounts.create_submit') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="user-plus" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ __('accounts.temp_heading') }}</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('accounts.temp_intro', ['name' => $createdName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-md border border-warning/30 bg-warning/10 p-3" x-data="{ copied: false }">
|
||||
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning">
|
||||
<x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('accounts.temp_warning') }}
|
||||
</p>
|
||||
<label class="mt-3 mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('accounts.temp_label') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input x-ref="pw" type="text" readonly value="{{ $tempPassword }}" x-on:click="$el.select()"
|
||||
class="h-9 w-full rounded-md border border-line bg-void px-3 font-mono text-sm tracking-wide text-ink focus:outline-none" />
|
||||
<x-btn variant="secondary" class="shrink-0"
|
||||
x-on:click="navigator.clipboard.writeText($refs.pw.value); copied = true; setTimeout(() => copied = false, 1500)">
|
||||
<x-icon name="save" class="h-3.5 w-3.5" />
|
||||
<span x-text="copied ? @js(__('accounts.temp_copied')) : @js(__('accounts.temp_copy'))"></span>
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end">
|
||||
<x-btn variant="primary" wire:click="finish">{{ __('accounts.done') }}</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -1,5 +1,54 @@
|
|||
<div class="space-y-5">
|
||||
<x-panel :title="__('settings.users_title')" :subtitle="__('settings.users_subtitle')">
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('settings.coming_soon') }}</p>
|
||||
<x-panel :title="__('accounts.title')" :subtitle="__('accounts.subtitle')">
|
||||
<x-slot:actions>
|
||||
<x-btn variant="accent" wire:click="create">
|
||||
<x-icon name="user-plus" class="h-3.5 w-3.5" /> {{ __('accounts.create') }}
|
||||
</x-btn>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="divide-y divide-line">
|
||||
@forelse ($users as $user)
|
||||
@php $isSelf = $user->getKey() === $currentId; @endphp
|
||||
<div class="flex flex-col gap-3 py-3 sm:flex-row sm:items-center">
|
||||
<span @class([
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||
'border-accent/30 bg-accent/10 text-accent-text' => $isSelf,
|
||||
'border-line bg-inset text-ink-3' => ! $isSelf,
|
||||
])>
|
||||
<x-icon name="user-plus" class="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="truncate text-sm text-ink">{{ $user->name }}</p>
|
||||
@if ($isSelf)
|
||||
<x-badge tone="accent">{{ __('accounts.you') }}</x-badge>
|
||||
@endif
|
||||
@if ($user->hasTwoFactorEnabled())
|
||||
<x-badge tone="cyan">
|
||||
<x-icon name="shield" class="h-3 w-3" /> {{ __('accounts.twofa_on') }}
|
||||
</x-badge>
|
||||
@else
|
||||
<x-badge tone="neutral">{{ __('accounts.twofa_off') }}</x-badge>
|
||||
@endif
|
||||
</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">
|
||||
<x-btn variant="secondary" wire:click="confirmLogout({{ $user->getKey() }})">
|
||||
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('accounts.logout') }}
|
||||
</x-btn>
|
||||
<x-btn variant="danger-soft" wire:click="confirmRemove({{ $user->getKey() }})">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('accounts.remove') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('accounts.none') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-panel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\CreateUser;
|
||||
use App\Livewire\Settings\Users;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MultiUserTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Insert a raw session row for $user (mirrors SessionService's table contract). */
|
||||
private function seedSession(User $user, string $id): void
|
||||
{
|
||||
DB::table('sessions')->insert([
|
||||
'id' => $id,
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '203.0.113.1',
|
||||
'user_agent' => 'Mozilla/5.0 Chrome',
|
||||
'payload' => base64_encode(serialize([])),
|
||||
'last_activity' => now()->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_makes_a_must_change_password_user_and_audits(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($admin)->test(CreateUser::class)
|
||||
->set('name', 'Erika Mustermann')
|
||||
->set('email', 'erika@example.com')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
// The clear-text temp password is revealed once in component state.
|
||||
->assertSet('createdName', 'Erika Mustermann')
|
||||
->assertSet('tempPassword', fn ($pw) => is_string($pw) && strlen($pw) === 16);
|
||||
|
||||
$new = User::where('email', 'erika@example.com')->first();
|
||||
$this->assertNotNull($new);
|
||||
$this->assertTrue($new->must_change_password);
|
||||
// Password is hashed at rest (never the clear text).
|
||||
$this->assertNotSame('', $new->password);
|
||||
$this->assertNotSame(16, strlen($new->password));
|
||||
|
||||
$this->assertDatabaseHas('audit_events', [
|
||||
'action' => 'user.create',
|
||||
'target' => 'erika@example.com',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_temp_password_actually_authenticates(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(CreateUser::class)
|
||||
->set('name', 'Temp User')
|
||||
->set('email', 'temp@example.com')
|
||||
->call('save');
|
||||
|
||||
$temp = $component->get('tempPassword');
|
||||
$new = User::where('email', 'temp@example.com')->first();
|
||||
|
||||
$this->assertTrue(Hash::check($temp, $new->password));
|
||||
}
|
||||
|
||||
public function test_create_rejects_duplicate_email(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
User::factory()->create(['email' => 'taken@example.com']);
|
||||
|
||||
Livewire::actingAs($admin)->test(CreateUser::class)
|
||||
->set('name', 'Dup')
|
||||
->set('email', 'taken@example.com')
|
||||
->call('save')
|
||||
->assertHasErrors('email');
|
||||
|
||||
$this->assertSame(2, User::count());
|
||||
}
|
||||
|
||||
public function test_finish_dispatches_users_changed_and_closes(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($admin)->test(CreateUser::class)
|
||||
->call('finish')
|
||||
->assertDispatched('usersChanged')
|
||||
->assertDispatched('closeModal');
|
||||
}
|
||||
|
||||
public function test_cannot_delete_self(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
User::factory()->create(); // keep count > 1 so only the self-guard is in play
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('confirmRemove', $admin->id)
|
||||
->assertNotDispatched('openModal');
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('remove', $admin->id);
|
||||
|
||||
$this->assertDatabaseHas('users', ['id' => $admin->id]);
|
||||
$this->assertSame(0, AuditEvent::where('action', 'user.delete')->count());
|
||||
}
|
||||
|
||||
public function test_cannot_delete_the_last_user(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
$this->assertSame(1, User::count());
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('confirmRemove', $admin->id)
|
||||
->assertNotDispatched('openModal');
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('remove', $admin->id);
|
||||
|
||||
$this->assertDatabaseHas('users', ['id' => $admin->id]);
|
||||
}
|
||||
|
||||
public function test_remove_confirm_opens_modal_with_audit_descriptor(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$victim = User::factory()->create(['email' => 'victim@example.com']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('confirmRemove', $victim->id)
|
||||
->assertDispatched('openModal');
|
||||
}
|
||||
|
||||
public function test_remove_deletes_user_kills_sessions_and_audits(): void
|
||||
{
|
||||
config(['session.driver' => 'database']);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$victim = User::factory()->create(['email' => 'victim@example.com', 'remember_token' => 'victim-token']);
|
||||
$this->seedSession($victim, 'victim-sess');
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('remove', $victim->id);
|
||||
|
||||
// User gone.
|
||||
$this->assertDatabaseMissing('users', ['id' => $victim->id]);
|
||||
// SessionService::logoutUserEverywhere ran BEFORE delete — rows cleared.
|
||||
$this->assertSame(0, DB::table('sessions')->where('id', 'victim-sess')->count());
|
||||
// Audit row written with the current actor.
|
||||
$this->assertDatabaseHas('audit_events', [
|
||||
'action' => 'user.delete',
|
||||
'target' => 'victim@example.com',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_per_row_logout_rotates_target_token_and_audits(): void
|
||||
{
|
||||
config(['session.driver' => 'database']);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$target = User::factory()->create(['email' => 'target@example.com', 'remember_token' => 'target-token']);
|
||||
$this->seedSession($target, 'target-sess');
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('logout', $target->id);
|
||||
|
||||
// Target still exists but is signed out everywhere + token rotated.
|
||||
$this->assertDatabaseHas('users', ['id' => $target->id]);
|
||||
$this->assertSame(0, DB::table('sessions')->where('id', 'target-sess')->count());
|
||||
$this->assertNotSame('target-token', $target->fresh()->remember_token);
|
||||
$this->assertNotNull($target->fresh()->remember_token);
|
||||
|
||||
$this->assertDatabaseHas('audit_events', [
|
||||
'action' => 'user.logout',
|
||||
'target' => 'target@example.com',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_force_logout_self(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('confirmLogout', $admin->id)
|
||||
->assertNotDispatched('openModal');
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->call('logout', $admin->id);
|
||||
|
||||
$this->assertSame(0, AuditEvent::where('action', 'user.logout')->count());
|
||||
}
|
||||
|
||||
public function test_list_renders_all_users(): void
|
||||
{
|
||||
$admin = User::factory()->create(['name' => 'Aaron Admin']);
|
||||
User::factory()->create(['name' => 'Bonnie Boss']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Users::class)
|
||||
->assertOk()
|
||||
->assertViewHas('users', fn ($users) => $users->count() === 2)
|
||||
->assertSee('Aaron Admin')
|
||||
->assertSee('Bonnie Boss');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue