From beb2f9a185a555fc65c995c982acae40db18e9d3 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 30 Oct 2025 14:21:32 +0100 Subject: [PATCH 1/8] Implement change password command helper --- .../Commands/UpdateProfilePasswordCommand.php | 50 ++++--------- demo/app/Sharp/Profile/ProfileSingleShow.php | 10 +-- resources/lang/en/auth.php | 10 +++ resources/lang/fr/auth.php | 10 +++ .../Command/IsChangePasswordCommandTrait.php | 71 +++++++++++++++++++ 5 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 src/Auth/Password/Command/IsChangePasswordCommandTrait.php diff --git a/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php b/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php index 8600280ad..eee66ca1f 100644 --- a/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php +++ b/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php @@ -2,53 +2,31 @@ namespace App\Sharp\Profile\Commands; +use Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait; use Code16\Sharp\EntityList\Commands\SingleInstanceCommand; -use Code16\Sharp\Exceptions\Form\SharpApplicativeException; -use Code16\Sharp\Form\Fields\SharpFormTextField; -use Code16\Sharp\Utils\Fields\FieldsContainer; +use Illuminate\Validation\Rules\Password; class UpdateProfilePasswordCommand extends SingleInstanceCommand { - public function label(): ?string - { - return 'Update password...'; - } + use IsChangePasswordCommandTrait; - public function buildFormFields(FieldsContainer $formFields): void + public function buildCommandConfig(): void { - $formFields - ->addField( - SharpFormTextField::make('password') - ->setLabel('Current password') - ->setInputTypePassword() - ) - ->addField( - SharpFormTextField::make('new_password') - ->setLabel('New password') - ->setInputTypePassword() - ) - ->addField( - SharpFormTextField::make('new_password_confirmation') - ->setLabel('Confirm new password') - ->setInputTypePassword() + $this->configureConfirmPassword() + ->configurePasswordRule( + Password::min(8) + ->numbers() + ->symbols() + ->uncompromised() ); } protected function executeSingle(array $data): array { - $this->validate($data, [ - 'password' => 'required', - 'new_password' => ['required', 'confirmed', 'string', 'min:8'], - ]); - - $granted = auth()->validate([ - 'email' => auth()->user()->email, - 'password' => $data['password'], - ]); - - if (! $granted) { - throw new SharpApplicativeException('Your current password is invalid.'); - } + // We do not really update the password in the context of the demo + // auth()->user()->update([ + // 'password' => $data['password'], + // ]); $this->notify('Password updated!'); diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index 0471a5810..fde266157 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -46,12 +46,12 @@ public function buildShowConfig(): void public function getInstanceCommands(): ?array { - return array_merge( - [UpdateProfilePasswordCommand::class], - config('sharp.auth.2fa.handler') === 'totp' + return [ + UpdateProfilePasswordCommand::class, + ...sharp()->config()->get('auth.2fa.handler') === 'totp' ? [Activate2faCommand::class, Deactivate2faCommand::class] - : [] - ); + : [], + ]; } public function findSingle(): array diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 2163822b9..ffc2819fa 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -28,4 +28,14 @@ ], ], ], + 'password_change' => [ + 'command' => [ + 'label' => 'Change password...', + 'fields' => [ + 'current_password' => 'Current password', + 'new_password' => 'New password', + 'new_password_confirm' => 'Confirm new password', + ], + ], + ], ]; diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php index c6ad424ed..99211b2e5 100644 --- a/resources/lang/fr/auth.php +++ b/resources/lang/fr/auth.php @@ -28,4 +28,14 @@ ], ], ], + 'password_change' => [ + 'command' => [ + 'label' => 'Modifier le mot de passe...', + 'fields' => [ + 'current_password' => 'Mot de passe actuel', + 'new_password' => 'Nouveau mot de passe', + 'new_password_confirm' => 'Confirmer le nouveau mot de passe', + ], + ], + ], ]; diff --git a/src/Auth/Password/Command/IsChangePasswordCommandTrait.php b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php new file mode 100644 index 000000000..dd80ae84d --- /dev/null +++ b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php @@ -0,0 +1,71 @@ +addField( + SharpFormTextField::make('password') + ->setLabel(trans('sharp::auth.password_change.command.fields.current_password')) + ->setInputTypePassword() + ) + ->addField( + SharpFormTextField::make('new_password') + ->setLabel(trans('sharp::auth.password_change.command.fields.new_password')) + ->setInputTypePassword() + ) + ->when( + $this->confirmPassword, + fn (FieldsContainer $formFields) => $formFields->addField( + SharpFormTextField::make('new_password_confirmation') + ->setLabel(trans('sharp::auth.password_change.command.fields.new_password_confirm')) + ->setInputTypePassword() + ) + ); + } + + public function rules(): array + { + return [ + 'password' => [ + 'required', + 'current_password', + ], + 'new_password' => [ + 'required', + 'string', + $this->passwordRule ?? Password::min(8), + ...$this->confirmPassword ? ['confirmed'] : [], + ], + ]; + } + + protected function configureConfirmPassword(?bool $confirmPassword = true): self + { + $this->confirmPassword = $confirmPassword; + + return $this; + } + + protected function configurePasswordRule(Password $passwordRule): self + { + $this->passwordRule = $passwordRule; + + return $this; + } +} From bd290bbad95d35daa77f82eec9aff81ba7acba91 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 30 Oct 2025 14:33:50 +0100 Subject: [PATCH 2/8] Add rate limiting --- resources/lang/en/auth.php | 1 + resources/lang/fr/auth.php | 1 + .../Command/IsChangePasswordCommandTrait.php | 40 ++++++++++++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index ffc2819fa..aff0899c3 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -36,6 +36,7 @@ 'new_password' => 'New password', 'new_password_confirm' => 'Confirm new password', ], + 'rate_limit_exceeded' => 'You have made too many attempts. Please try again in :seconds seconds.', ], ], ]; diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php index 99211b2e5..7c1e7b02e 100644 --- a/resources/lang/fr/auth.php +++ b/resources/lang/fr/auth.php @@ -36,6 +36,7 @@ 'new_password' => 'Nouveau mot de passe', 'new_password_confirm' => 'Confirmer le nouveau mot de passe', ], + 'rate_limit_exceeded' => 'Vous avez effectué trop de tentatives. Veuillez réessayer dans :seconds secondes.', ], ], ]; diff --git a/src/Auth/Password/Command/IsChangePasswordCommandTrait.php b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php index dd80ae84d..88d0f00a6 100644 --- a/src/Auth/Password/Command/IsChangePasswordCommandTrait.php +++ b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php @@ -2,8 +2,10 @@ namespace Code16\Sharp\Auth\Password\Command; +use Code16\Sharp\Exceptions\Form\SharpApplicativeException; use Code16\Sharp\Form\Fields\SharpFormTextField; use Code16\Sharp\Utils\Fields\FieldsContainer; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; trait IsChangePasswordCommandTrait @@ -41,18 +43,34 @@ public function buildFormFields(FieldsContainer $formFields): void public function rules(): array { - return [ - 'password' => [ - 'required', - 'current_password', + $rules = RateLimiter::attempt( + 'sharp-password-change-'.auth()->id(), + 3, + fn () => [ + 'password' => [ + 'required', + 'current_password', + ], + 'new_password' => [ + 'required', + 'string', + $this->passwordRule ?? Password::min(8), + ...$this->confirmPassword ? ['confirmed'] : [], + ], ], - 'new_password' => [ - 'required', - 'string', - $this->passwordRule ?? Password::min(8), - ...$this->confirmPassword ? ['confirmed'] : [], - ], - ]; + ); + + if (! $rules) { + throw new SharpApplicativeException( + trans( + 'sharp::auth.password_change.command.rate_limit_exceeded', [ + 'seconds' => RateLimiter::availableIn('sharp-password-change-'.auth()->id()), + ] + ) + ); + } + + return $rules; } protected function configureConfirmPassword(?bool $confirmPassword = true): self From 1f36febdcbc01c7c7c15af1b0720180c8f87cc3f Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 30 Oct 2025 14:53:37 +0100 Subject: [PATCH 3/8] Naming --- ...teProfilePasswordCommand.php => ChangePasswordCommand.php} | 2 +- demo/app/Sharp/Profile/ProfileSingleShow.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename demo/app/Sharp/Profile/Commands/{UpdateProfilePasswordCommand.php => ChangePasswordCommand.php} (93%) diff --git a/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php b/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php similarity index 93% rename from demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php rename to demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php index eee66ca1f..768cfb6be 100644 --- a/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php +++ b/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php @@ -6,7 +6,7 @@ use Code16\Sharp\EntityList\Commands\SingleInstanceCommand; use Illuminate\Validation\Rules\Password; -class UpdateProfilePasswordCommand extends SingleInstanceCommand +class ChangePasswordCommand extends SingleInstanceCommand { use IsChangePasswordCommandTrait; diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index fde266157..07f51acd6 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -3,8 +3,8 @@ namespace App\Sharp\Profile; use App\Sharp\Profile\Commands\Activate2faCommand; +use App\Sharp\Profile\Commands\ChangePasswordCommand; use App\Sharp\Profile\Commands\Deactivate2faCommand; -use App\Sharp\Profile\Commands\UpdateProfilePasswordCommand; use Code16\Sharp\Show\Fields\SharpShowPictureField; use Code16\Sharp\Show\Fields\SharpShowTextField; use Code16\Sharp\Show\Layout\ShowLayout; @@ -47,7 +47,7 @@ public function buildShowConfig(): void public function getInstanceCommands(): ?array { return [ - UpdateProfilePasswordCommand::class, + ChangePasswordCommand::class, ...sharp()->config()->get('auth.2fa.handler') === 'totp' ? [Activate2faCommand::class, Deactivate2faCommand::class] : [], From 6c50e6af3aeae8d9eef97be2f3f20ac876c39ab0 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 30 Oct 2025 14:53:42 +0100 Subject: [PATCH 4/8] Add tests --- .../Auth/ChangePasswordCommandTraitTest.php | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/Http/Auth/ChangePasswordCommandTraitTest.php diff --git a/tests/Http/Auth/ChangePasswordCommandTraitTest.php b/tests/Http/Auth/ChangePasswordCommandTraitTest.php new file mode 100644 index 000000000..bee63c80d --- /dev/null +++ b/tests/Http/Auth/ChangePasswordCommandTraitTest.php @@ -0,0 +1,177 @@ +config()->declareEntity(SinglePersonEntity::class); + + login(new User([ + 'id' => 123, // ensure RateLimiter key is unique in tests + 'password' => Hash::make('secret'), + ])); +}); + +it('exposes proper form fields and label (without confirmation) for change password command', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'change_password' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + protected function executeSingle(array $data): array + { + // no-op in tests + return $this->reload(); + } + }, + ]; + } + }); + + // Fetch the command form (single show variant) + $this + ->getJson(route('code16.sharp.api.show.command.singleInstance.form', [ + 'entityKey' => 'single-person', + 'commandKey' => 'change_password', + ])) + ->assertOk() + ->assertJson(function (Assert $json) { + $json + ->where('config.title', trans('sharp::auth.password_change.command.label')) + ->where('fields.password.key', 'password') + ->where('fields.new_password.key', 'new_password') + ->missing('fields.new_password_confirmation') + ->etc(); + }); +}); + +it('shows confirmation field when enabled and enforces custom password rule and confirmation', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + // enable confirmation + a stronger rule + 'change_password_confirm' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + public function buildCommandConfig(): void + { + $this->configureConfirmPassword() + ->configurePasswordRule(Password::min(8)->numbers()); + } + + protected function executeSingle(array $data): array + { + return $this->reload(); + } + }, + ]; + } + }); + + // Form contains the confirmation field + $this + ->getJson(route('code16.sharp.api.show.command.singleInstance.form', [ + 'entityKey' => 'single-person', + 'commandKey' => 'change_password_confirm', + ])) + ->assertOk() + ->assertJson(function (Assert $json) { + $json + ->where('fields.password.key', 'password') + ->where('fields.new_password.key', 'new_password') + ->where('fields.new_password_confirmation.key', 'new_password_confirmation') + ->etc(); + }); + + // Fails when confirmation is missing/mismatch + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'password' => 'secret', + 'new_password' => 'Password1', // missing confirmation + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['new_password']); + + // Fails when password rule is not satisfied (requires number) + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'password' => 'secret', + 'new_password' => 'Password!', + 'new_password_confirmation' => 'Password!', + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['new_password']); + + // Succeeds with valid data + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'password' => 'secret', + 'new_password' => 'Password1!', + 'new_password_confirmation' => 'Password1!', + ], + ]) + ->assertOk(); +}); + +it('rate limits after too many attempts and returns a helpful message', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'change_password_rl' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + protected function executeSingle(array $data): array + { + return $this->reload(); + } + }, + ]; + } + }); + + // Trigger 3 attempts (invalid to keep trying) + for ($i = 0; $i < 3; $i++) { + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_rl']), [ + 'data' => [ + // missing fields triggers validation and consumes an attempt + ], + ]) + ->assertUnprocessable(); + } + + // 4th attempt should be blocked by rate limiter with SharpApplicativeException (417) + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_rl']), [ + 'data' => [ + // still invalid + ], + ]) + ->assertStatus(417) + ->assertJson(function (Assert $json) { + $json->where('message', function ($message) { + return is_string($message) && str_starts_with($message, 'You have made too many attempts.'); + }); + }); +}); From 421b078db2910bc4a4b81ec5b1a5f2b918e5a74d Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 31 Oct 2025 10:39:05 +0100 Subject: [PATCH 5/8] Document + add config for current_password --- .../Commands/ChangePasswordCommand.php | 2 +- docs/guide/authentication.md | 58 ++++++++++++++++++- .../Command/IsChangePasswordCommandTrait.php | 26 ++++++--- .../Auth/ChangePasswordCommandTraitTest.php | 48 +++++++++++++++ 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php b/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php index 768cfb6be..d0b6299c6 100644 --- a/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php +++ b/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php @@ -25,7 +25,7 @@ protected function executeSingle(array $data): array { // We do not really update the password in the context of the demo // auth()->user()->update([ - // 'password' => $data['password'], + // 'password' => $data['new_password'], // ]); $this->notify('Password updated!'); diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index f152aaa84..25aabd3c1 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -243,7 +243,7 @@ class My2faNotificationHandler extends Sharp2faNotificationHandler // or Sharp2f ## Forgotten password -You can activate the classic Laravel Breeze workflow of forgotten password with a simple config: +You can activate the classic Laravel workflow of forgotten password with a simple config: ```php class SharpServiceProvider extends SharpAppServiceProvider @@ -306,9 +306,62 @@ class SharpServiceProvider extends SharpAppServiceProvider These customizations will not interfere with any default behavior that you may have implemented for your app, outside Sharp. +## Allow the current user to change his password + +Sharp provides a helper trait to quickly build a command that lets the currently authenticated user change his password: `Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait`. Using this trait, you can quickly build a Sharp command, with a few configuration options. + +The trat will take care of the form, validation and rate-limiting. Note that: + +- This helper is designed for the “current user changes his own password” scenario. If you need admin-managed password resets for other users, implement a different command with the proper authorization checks. +- Persisting the new password is up to you (see example below). + +### Configuration and behavior + +You can configure the behavior of the command with the following methods (should be called in your `buildCommandConfig()` method): + +- `configureConfirmPassword(?bool $confirm = true)`: enable password confirmation (false by default) +- `configurePasswordRule(Password $rule)`: change the default password validation rule (default: `Password::min(8)`) +- `configureValidateCurrentPassword(?bool $validate = true)`: if true, a `password` field that uses Laravel’s `current_password` rule, which compares against the currently authenticated user’s stored password, is added. Make sure your `User` model stores a hashed password as usual. (true by default) + +### Example + +```php +use Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait; +// ... + +class ChangePasswordCommand extends SingleInstanceCommand +{ + use IsChangePasswordCommandTrait; + + public function buildCommandConfig(): void + { + $this->configureConfirmPassword() + ->configurePasswordRule( + Password::min(8) + ->numbers() + ->symbols() + ->uncompromised() + ); + } + + protected function executeSingle(array $data): array + { + // The trait handles validation and rate limiting. + + auth()->user()->update([ + 'password' => $data['new_password'], // Considering hashing is done by the model (cast) + ]); + + $this->notify('Password updated!'); + + return $this->reload(); + } +} +``` + ## User impersonation (dev only) -At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows to do that out of the box: +At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows doing that out of the box: ```php class SharpServiceProvider extends SharpAppServiceProvider @@ -392,3 +445,4 @@ class SharpServiceProvider extends SharpAppServiceProvider } } ``` + diff --git a/src/Auth/Password/Command/IsChangePasswordCommandTrait.php b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php index 88d0f00a6..460e716f2 100644 --- a/src/Auth/Password/Command/IsChangePasswordCommandTrait.php +++ b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php @@ -11,6 +11,7 @@ trait IsChangePasswordCommandTrait { private bool $confirmPassword = false; + private bool $validateCurrentPassword = true; private ?Password $passwordRule = null; public function label(): ?string @@ -21,10 +22,13 @@ public function label(): ?string public function buildFormFields(FieldsContainer $formFields): void { $formFields - ->addField( - SharpFormTextField::make('password') - ->setLabel(trans('sharp::auth.password_change.command.fields.current_password')) - ->setInputTypePassword() + ->when( + $this->validateCurrentPassword, + fn (FieldsContainer $formFields) => $formFields->addField( + SharpFormTextField::make('password') + ->setLabel(trans('sharp::auth.password_change.command.fields.current_password')) + ->setInputTypePassword() + ) ) ->addField( SharpFormTextField::make('new_password') @@ -47,10 +51,9 @@ public function rules(): array 'sharp-password-change-'.auth()->id(), 3, fn () => [ - 'password' => [ - 'required', - 'current_password', - ], + ...$this->validateCurrentPassword + ? ['password' => ['required', 'current_password']] + : [], 'new_password' => [ 'required', 'string', @@ -80,6 +83,13 @@ protected function configureConfirmPassword(?bool $confirmPassword = true): self return $this; } + protected function configureValidateCurrentPassword(?bool $validateCurrentPassword = true): self + { + $this->validateCurrentPassword = $validateCurrentPassword; + + return $this; + } + protected function configurePasswordRule(Password $passwordRule): self { $this->passwordRule = $passwordRule; diff --git a/tests/Http/Auth/ChangePasswordCommandTraitTest.php b/tests/Http/Auth/ChangePasswordCommandTraitTest.php index bee63c80d..1653bc36b 100644 --- a/tests/Http/Auth/ChangePasswordCommandTraitTest.php +++ b/tests/Http/Auth/ChangePasswordCommandTraitTest.php @@ -131,6 +131,54 @@ protected function executeSingle(array $data): array ->assertOk(); }); +it('allows to hide the current password field', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'change_password_confirm' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + public function buildCommandConfig(): void + { + $this->configureValidateCurrentPassword(false); + } + + protected function executeSingle(array $data): array + { + return $this->reload(); + } + }, + ]; + } + }); + + // Form does not contain the current password field + $this + ->getJson(route('code16.sharp.api.show.command.singleInstance.form', [ + 'entityKey' => 'single-person', + 'commandKey' => 'change_password_confirm', + ])) + ->assertOk() + ->assertJson(function (Assert $json) { + $json + ->missing('fields.password') + ->where('fields.new_password.key', 'new_password') + ->etc(); + }); + + // Succeeds with valid data + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'new_password' => 'Password1!', + ], + ]) + ->assertOk(); +}); + it('rate limits after too many attempts and returns a helpful message', function () { fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow { From 50ce3b82fdd13fd0787459aa586d4d5897de245e Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 31 Oct 2025 10:46:36 +0100 Subject: [PATCH 6/8] Improve doc --- docs/guide/authentication.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 25aabd3c1..db12ba00a 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -319,11 +319,11 @@ The trat will take care of the form, validation and rate-limiting. Note that: You can configure the behavior of the command with the following methods (should be called in your `buildCommandConfig()` method): -- `configureConfirmPassword(?bool $confirm = true)`: enable password confirmation (false by default) -- `configurePasswordRule(Password $rule)`: change the default password validation rule (default: `Password::min(8)`) -- `configureValidateCurrentPassword(?bool $validate = true)`: if true, a `password` field that uses Laravel’s `current_password` rule, which compares against the currently authenticated user’s stored password, is added. Make sure your `User` model stores a hashed password as usual. (true by default) +- `configureConfirmPassword(?bool $confirm = true)`: (false by default) enable password confirmation. +- `configurePasswordRule(Password $rule)`: (default: `Password::min(8)`) change the default password validation rule. +- `configureValidateCurrentPassword(?bool $validate = true)`: (true by default) if true, a `password` field that uses Laravel’s `current_password` rule (which compares against the currently authenticated user’s stored password) is added. Make sure you use Eloquent, and that your `User` model stores a hashed password as usual. -### Example +### Full example ```php use Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait; @@ -359,6 +359,10 @@ class ChangePasswordCommand extends SingleInstanceCommand } ``` +::: info +In this example we chose to create a `SingleInstanceCommand`, since it’s a common use-case to attach such a command to a "Profile" single Show Page that could be [placed in the user menu](building-menu.md#add-links-in-the-user-profile-menu), but you can decide to create an `EntityCommand` or even an `InstanceCommand` instead. +::: + ## User impersonation (dev only) At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows doing that out of the box: From f9bb18d9e512750a8a9c3e85e5b0185cd7498fbb Mon Sep 17 00:00:00 2001 From: Philippe Lonchampt Date: Fri, 31 Oct 2025 11:12:30 +0100 Subject: [PATCH 7/8] Update docs/guide/authentication.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index db12ba00a..7515b7911 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -310,7 +310,7 @@ These customizations will not interfere with any default behavior that you may h Sharp provides a helper trait to quickly build a command that lets the currently authenticated user change his password: `Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait`. Using this trait, you can quickly build a Sharp command, with a few configuration options. -The trat will take care of the form, validation and rate-limiting. Note that: +The trait will take care of the form, validation and rate-limiting. Note that: - This helper is designed for the “current user changes his own password” scenario. If you need admin-managed password resets for other users, implement a different command with the proper authorization checks. - Persisting the new password is up to you (see example below). From 067050478b75e9e1554f388668318f637403871f Mon Sep 17 00:00:00 2001 From: Philippe Lonchampt Date: Fri, 31 Oct 2025 11:19:35 +0100 Subject: [PATCH 8/8] Update docs/guide/authentication.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 7515b7911..4b253298a 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -365,7 +365,7 @@ In this example we chose to create a `SingleInstanceCommand`, since it’s a com ## User impersonation (dev only) -At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows doing that out of the box: +At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows you to do that out of the box: ```php class SharpServiceProvider extends SharpAppServiceProvider