From d08670270ebef6978e165b1d990e40f4e0c007a9 Mon Sep 17 00:00:00 2001 From: boban Date: Thu, 25 Jun 2026 20:29:15 +0200 Subject: [PATCH] feat(accounts): let the operator set a new user's password directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Add-account modal only ever generated a one-time password — there was no way to set one. Add an optional password field (with a show/hide toggle): - blank → previous behaviour: a strong one-time password is generated, the account is flagged must_change_password, and the clear text is revealed once. - filled → that becomes the user's own login (validated min 12 / mixed case / digit); must_change_password stays false (no forced rotation) and nothing is revealed. Browser-verified: the modal shows the password field + hint; both paths covered by tests. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Modals/CreateUser.php | 38 ++++++++++++++----- lang/de/accounts.php | 6 ++- lang/en/accounts.php | 6 ++- .../livewire/modals/create-user.blade.php | 15 ++++++++ tests/Feature/MultiUserTest.php | 33 ++++++++++++++++ 5 files changed, 86 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Modals/CreateUser.php b/app/Livewire/Modals/CreateUser.php index 1013224..c4ccbf8 100644 --- a/app/Livewire/Modals/CreateUser.php +++ b/app/Livewire/Modals/CreateUser.php @@ -8,14 +8,14 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Password; 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. + * Form modal: add a new administrator account. Every account is a full admin (no roles). The creator + * may set a password directly, or leave it blank — then a strong one-time 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. */ class CreateUser extends ModalComponent { @@ -23,6 +23,9 @@ class CreateUser extends ModalComponent public string $email = ''; + /** Optional operator-set password; blank = generate a one-time password and reveal it once. */ + public string $password = ''; + /** Clear-text temp password, held in component state only to reveal it once. */ public ?string $tempPassword = null; @@ -42,6 +45,8 @@ class CreateUser extends ModalComponent return [ 'name' => ['required', 'string', 'max:120'], '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(12)->mixedCase()->numbers()], ]; } @@ -53,6 +58,7 @@ class CreateUser extends ModalComponent return [ 'name' => __('accounts.name_label'), 'email' => __('accounts.email_label'), + 'password' => __('accounts.password_label'), ]; } @@ -68,13 +74,16 @@ class CreateUser extends ModalComponent return; } - $temp = Str::password(16); + // Operator-set password = the user's own login (no forced rotation). Blank = generate a strong + // one-time password that the user must rotate, and reveal it once below. + $operatorSet = $this->password !== ''; + $password = $operatorSet ? $this->password : Str::password(16); $user = User::create([ 'name' => trim($data['name']), 'email' => $email, - 'password' => Hash::make($temp), - 'must_change_password' => true, + 'password' => Hash::make($password), + 'must_change_password' => ! $operatorSet, ]); AuditEvent::create([ @@ -85,9 +94,18 @@ class CreateUser extends ModalComponent 'ip' => request()->ip(), ]); - // Reveal the temp password ONCE — swaps the form for the reveal panel. + // Operator already knows the password they set → no reveal; just confirm and close. + if ($operatorSet) { + $this->dispatch('usersChanged'); + $this->dispatch('notify', message: __('accounts.created_notify', ['name' => $user->name])); + $this->closeModal(); + + return; + } + + // Reveal the generated one-time password ONCE — swaps the form for the reveal panel. $this->createdName = $user->name; - $this->tempPassword = $temp; + $this->tempPassword = $password; } /** "Fertig": reload the list and close. */ diff --git a/lang/de/accounts.php b/lang/de/accounts.php index a634050..60742b6 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -16,11 +16,15 @@ return [ // Create modal 'create_title' => 'Konto hinzufügen', - 'create_subtitle' => 'Das neue Konto ist vollwertiger Administrator und muss beim ersten Anmelden ein eigenes Passwort setzen.', + 'create_subtitle' => 'Vollwertiger Administrator. Setze ein Passwort — oder lass es leer, dann wird ein Einmal-Passwort erzeugt und einmalig angezeigt.', 'name_label' => 'Name', 'name_placeholder' => 'Erika Mustermann', 'email_label' => 'E-Mail', 'email_placeholder' => 'erika@example.com', + 'password_label' => 'Passwort', + 'password_placeholder' => 'Leer = Einmal-Passwort erzeugen', + 'password_hint' => 'Optional. Min. 12 Zeichen, Groß-/Kleinschreibung + Zahl. Leer lassen, um ein sicheres Einmal-Passwort zu erzeugen.', + 'created_notify' => 'Konto :name angelegt.', 'create_submit' => 'Konto anlegen', // Temp-password reveal (shown once) diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 6e86edd..270e8c1 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -16,11 +16,15 @@ return [ // 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.', + 'create_subtitle' => 'Full administrator. Set a password — or leave it blank to generate a one-time password shown once.', 'name_label' => 'Name', 'name_placeholder' => 'Jane Doe', 'email_label' => 'Email', 'email_placeholder' => 'jane@example.com', + 'password_label' => 'Password', + 'password_placeholder' => 'Blank = generate a one-time password', + 'password_hint' => 'Optional. Min. 12 characters, upper/lowercase + a digit. Leave blank to generate a secure one-time password.', + 'created_notify' => 'Account :name created.', 'create_submit' => 'Create account', // Temp-password reveal (shown once) diff --git a/resources/views/livewire/modals/create-user.blade.php b/resources/views/livewire/modals/create-user.blade.php index 2415e11..64ae603 100644 --- a/resources/views/livewire/modals/create-user.blade.php +++ b/resources/views/livewire/modals/create-user.blade.php @@ -24,6 +24,21 @@ 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')

{{ $message }}

@enderror + +
+ +
+ + +
+ @error('password')

{{ $message }}

@enderror +

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

+
diff --git a/tests/Feature/MultiUserTest.php b/tests/Feature/MultiUserTest.php index c011c32..0ab1fa2 100644 --- a/tests/Feature/MultiUserTest.php +++ b/tests/Feature/MultiUserTest.php @@ -73,6 +73,39 @@ class MultiUserTest extends TestCase $this->assertTrue(Hash::check($temp, $new->password)); } + public function test_create_with_an_operator_password_uses_it_and_skips_rotation(): void + { + $admin = User::factory()->create(); + + Livewire::actingAs($admin)->test(CreateUser::class) + ->set('name', 'Set Pw') + ->set('email', 'setpw@example.com') + ->set('password', 'Chosen-Pass-123') + ->call('save') + ->assertHasNoErrors() + ->assertSet('tempPassword', null) // no reveal — the operator already knows it + ->assertDispatched('usersChanged'); + + $new = User::where('email', 'setpw@example.com')->first(); + $this->assertNotNull($new); + $this->assertFalse($new->must_change_password); // operator-set = the user's own login + $this->assertTrue(Hash::check('Chosen-Pass-123', $new->password)); + } + + public function test_create_rejects_a_weak_operator_password(): void + { + $admin = User::factory()->create(); + + Livewire::actingAs($admin)->test(CreateUser::class) + ->set('name', 'Weak') + ->set('email', 'weak@example.com') + ->set('password', 'short') + ->call('save') + ->assertHasErrors('password'); + + $this->assertNull(User::where('email', 'weak@example.com')->first()); + } + public function test_create_rejects_duplicate_email(): void { $admin = User::factory()->create();