From 4ced37ac40f80f4ccf6f24ee8cb7976a41e66fc1 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 23:39:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20multi-user=20admin=20manageme?= =?UTF-8?q?nt=20=E2=80=94=20create=20(temp=20pw),=20list,=20remove,=20forc?= =?UTF-8?q?e-logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Modals/CreateUser.php | 104 +++++++++ app/Livewire/Settings/Users.php | 142 +++++++++++- lang/de/accounts.php | 48 ++++ lang/en/accounts.php | 48 ++++ .../livewire/modals/create-user.blade.php | 68 ++++++ .../views/livewire/settings/users.blade.php | 53 ++++- tests/Feature/MultiUserTest.php | 213 ++++++++++++++++++ 7 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 app/Livewire/Modals/CreateUser.php create mode 100644 lang/de/accounts.php create mode 100644 lang/en/accounts.php create mode 100644 resources/views/livewire/modals/create-user.blade.php create mode 100644 tests/Feature/MultiUserTest.php diff --git a/app/Livewire/Modals/CreateUser.php b/app/Livewire/Modals/CreateUser.php new file mode 100644 index 0000000..1013224 --- /dev/null +++ b/app/Livewire/Modals/CreateUser.php @@ -0,0 +1,104 @@ +> + */ + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:120'], + 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], + ]; + } + + /** + * @return array + */ + 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'); + } +} diff --git a/app/Livewire/Settings/Users.php b/app/Livewire/Settings/Users.php index 1e0522e..9acbf6c 100644 --- a/app/Livewire/Settings/Users.php +++ b/app/Livewire/Settings/Users.php @@ -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(), + ]); } } diff --git a/lang/de/accounts.php b/lang/de/accounts.php new file mode 100644 index 0000000..a634050 --- /dev/null +++ b/lang/de/accounts.php @@ -0,0 +1,48 @@ + '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.', +]; diff --git a/lang/en/accounts.php b/lang/en/accounts.php new file mode 100644 index 0000000..6e86edd --- /dev/null +++ b/lang/en/accounts.php @@ -0,0 +1,48 @@ + '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.', +]; diff --git a/resources/views/livewire/modals/create-user.blade.php b/resources/views/livewire/modals/create-user.blade.php new file mode 100644 index 0000000..2415e11 --- /dev/null +++ b/resources/views/livewire/modals/create-user.blade.php @@ -0,0 +1,68 @@ +
+ @if ($tempPassword === null) +
+ + + +
+

{{ __('accounts.create_title') }}

+

{{ __('accounts.create_subtitle') }}

+
+
+ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ +
+ {{ __('common.cancel') }} + + + + {{ __('accounts.create_submit') }} + +
+ @else +
+ + + +
+

{{ __('accounts.temp_heading') }}

+

{{ __('accounts.temp_intro', ['name' => $createdName]) }}

+
+
+ +
+

+ {{ __('accounts.temp_warning') }} +

+ +
+ + + + + +
+
+ +
+ {{ __('accounts.done') }} +
+ @endif +
diff --git a/resources/views/livewire/settings/users.blade.php b/resources/views/livewire/settings/users.blade.php index 5b8d646..c438392 100644 --- a/resources/views/livewire/settings/users.blade.php +++ b/resources/views/livewire/settings/users.blade.php @@ -1,5 +1,54 @@
- -

{{ __('settings.coming_soon') }}

+ + + + {{ __('accounts.create') }} + + + +
+ @forelse ($users as $user) + @php $isSelf = $user->getKey() === $currentId; @endphp +
+ $isSelf, + 'border-line bg-inset text-ink-3' => ! $isSelf, + ])> + + + +
+
+

{{ $user->name }}

+ @if ($isSelf) + {{ __('accounts.you') }} + @endif + @if ($user->hasTwoFactorEnabled()) + + {{ __('accounts.twofa_on') }} + + @else + {{ __('accounts.twofa_off') }} + @endif +
+

{{ $user->email }}

+
+ + @unless ($isSelf) +
+ + {{ __('accounts.logout') }} + + + {{ __('accounts.remove') }} + +
+ @endunless +
+ @empty +

{{ __('accounts.none') }}

+ @endforelse +
diff --git a/tests/Feature/MultiUserTest.php b/tests/Feature/MultiUserTest.php new file mode 100644 index 0000000..66a21a2 --- /dev/null +++ b/tests/Feature/MultiUserTest.php @@ -0,0 +1,213 @@ +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'); + } +}