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
boban 2026-06-14 23:39:34 +02:00
parent 0cdc6c3a7b
commit 4ced37ac40
7 changed files with 673 additions and 3 deletions

View File

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

View File

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

48
lang/de/accounts.php Normal file
View File

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

48
lang/en/accounts.php Normal file
View File

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

View File

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

View File

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

View File

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