feat(accounts): let the operator set a new user's password directly

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 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 20:29:15 +02:00
parent 00a9d8b9a3
commit d08670270e
5 changed files with 86 additions and 12 deletions

View File

@ -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. */

View File

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

View File

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

View File

@ -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')<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 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">
<input wire:model="password" :type="show ? 'text' : 'password'" autocomplete="new-password" placeholder="{{ __('accounts.password_placeholder') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 pr-11 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<button type="button" @click="show = !show" :aria-label="show ? '{{ __('auth.hide_password') }}' : '{{ __('auth.toggle_password') }}'"
class="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-ink-3 transition-colors hover:text-accent-text focus:text-accent-text focus:outline-none">
<span x-show="!show"><x-icon name="eye" class="h-4 w-4" /></span>
<span x-show="show" x-cloak><x-icon name="eye-off" class="h-4 w-4" /></span>
</button>
</div>
@error('password')<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.password_hint') }}</p>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">

View File

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