Password validation only showing 1 password strength error at a time
Package
filament/filament
Package Version
4-alpha6
Laravel Version
12
Livewire Version
3
PHP Version
8.4
Problem description
Laravel's password validation is configured to return all validation errors at once:
https://github.com/laravel/framework/blob/bcfa7c32acc17a7e49892efc5040fcaae3f207da/src/Illuminate/Validation/Rules/Password.php#L322
Showing all the failures each time is important for UX. Let's say you have this password strength config in a service provider:
Password::defaults(function () {
return Password::min(8)
->letters()
->numbers()
->symbols()
->mixedCase();
});
When a user tries to register with a password that doesn't meet these criteria, the expectation is that a message for every failed criteria is displayed. Currently Filament displays these errors one by one:
First try - length failure:
Second try - length corrected, mixed case failure:
Third try - mixed case corrected, symbol failure:
Fourth try - symbol corrected, number failure:
Also, each validation failure hits the rate limiter which adds to the UX issues:
Showing password requirements as a helper text or a hint help a little, but displaying the full list of errors for the currently entered password is the most important visual feedback. I considered using a notification but I think that would feel strange. All other platforms display the list of errors under the password field. That's the UX users have been trained to expect.
Expected behavior
All password field validation errors should be displayed at once.
Steps to reproduce
- Clone reproduction repo
- Visit /register page and try using a password with just letters to start with.
Reproduction repository (issue will be closed if this is not valid)
https://github.com/binaryfire/filament4-password-validation
Relevant log output
This is how I'm working around it atm, in case it helps. Ideally the messages would be in an unordered list but validation error messages are escaped so I can't use HTML.
<?php
declare(strict_types=1);
namespace Packages\Laravel\Auth\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
class FilamentPasswordRule implements ValidationRule
{
protected array $failedRules = [];
protected Password $passwordRule;
public function __construct()
{
$this->passwordRule = Password::default();
}
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$this->failedRules = [];
if (!is_string($value)) {
$fail(__('The :attribute must be a string.', ['attribute' => $attribute]));
return;
}
// Get the applied rules from the Password instance
$appliedRules = $this->passwordRule->appliedRules();
// Check minimum length
if (strlen($value) < $appliedRules['min']) {
$this->failedRules[] = 'min_length';
}
// Check mixed case
if ($appliedRules['mixedCase'] && !preg_match('/(\p{Ll}+.*\p{Lu})|(\p{Lu}+.*\p{Ll})/u', $value)) {
$this->failedRules[] = 'mixed_case';
}
// Check letters
if ($appliedRules['letters'] && !preg_match('/\pL/u', $value)) {
$this->failedRules[] = 'letters';
}
// Check symbols
if ($appliedRules['symbols'] && !preg_match('/\p{Z}|\p{S}|\p{P}/u', $value)) {
$this->failedRules[] = 'symbols';
}
// Check numbers
if ($appliedRules['numbers'] && !preg_match('/\pN/u', $value)) {
$this->failedRules[] = 'numbers';
}
// If there are failed rules, construct and return the error message
if (!empty($this->failedRules)) {
$fail($this->constructMessage());
}
}
/**
* Construct the failure message
*/
protected function constructMessage(): string
{
$messageFragments = [];
foreach ($this->failedRules as $failedRule) {
switch ($failedRule) {
case 'min_length':
$min = $this->passwordRule->appliedRules()['min'];
$messageFragments[] = __('at least :min characters', ['min' => $min]);
break;
case 'mixed_case':
$messageFragments[] = __('at least one uppercase and one lowercase letter');
break;
case 'letters':
$messageFragments[] = __('at least one letter');
break;
case 'symbols':
$messageFragments[] = __('at least one symbol');
break;
case 'numbers':
$messageFragments[] = __('at least one number');
break;
}
}
// Construct the sentence
if (count($messageFragments) === 1) {
return __('The password must contain :requirement.', ['requirement' => $messageFragments[0]]);
} elseif (count($messageFragments) === 2) {
return __('The password must contain :first and :second.', [
'first' => $messageFragments[0],
'second' => $messageFragments[1]
]);
} else {
$lastMessageFragment = array_pop($messageFragments);
return __('The password must contain :requirements, and :last.', [
'requirements' => implode(', ', $messageFragments),
'last' => $lastMessageFragment
]);
}
}
}
PS. It'd be nice if validation messages supported html. Is there any reason they need to be escaped? It's not user-generated data so I don't think there's any risk using {!! !!}. Here's a common type of use case: https://youtu.be/_k4zwJIUPH0?si=-JzjZ_L-UNm_zu-A&t=62
@danharrin Now that https://github.com/filamentphp/filament/pull/16247 has been merged, I'll get on this
Thanks, but I'll handle it if you can't work out a way to validate using the built-in Password rule instead of creating a custom one
Closed by https://github.com/filamentphp/filament/pull/16741