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
parent
00a9d8b9a3
commit
d08670270e
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue