feat: complete jobcard operations and access controls
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
/**
|
||||
* Applies the same password rules to initial credentials and password resets.
|
||||
* It validates only; callers remain responsible for hashing accepted passwords.
|
||||
*/
|
||||
final class PasswordPolicy
|
||||
{
|
||||
public const MINIMUM_LENGTH = 12;
|
||||
|
||||
/** @return array{valid: bool, errors: list<string>} */
|
||||
public function validate(mixed $password): array
|
||||
{
|
||||
$errors = [];
|
||||
if (!is_string($password)) {
|
||||
return ['valid' => false, 'errors' => ['password must be a string']];
|
||||
}
|
||||
|
||||
if (mb_strlen($password) < self::MINIMUM_LENGTH) {
|
||||
$errors[] = 'minimum length';
|
||||
}
|
||||
if (preg_match('/\p{Lu}/u', $password) !== 1) {
|
||||
$errors[] = 'uppercase letter';
|
||||
}
|
||||
if (preg_match('/\p{Ll}/u', $password) !== 1) {
|
||||
$errors[] = 'lowercase letter';
|
||||
}
|
||||
if (preg_match('/\p{N}/u', $password) !== 1) {
|
||||
$errors[] = 'number';
|
||||
}
|
||||
if (preg_match('/[^\p{L}\p{N}\s]/u', $password) !== 1) {
|
||||
$errors[] = 'symbol';
|
||||
}
|
||||
if ($this->isCommonPlaceholder($password)) {
|
||||
$errors[] = 'common placeholder';
|
||||
}
|
||||
|
||||
return ['valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, errors: list<string>} */
|
||||
public function validateInitial(mixed $password): array
|
||||
{
|
||||
return $this->validate($password);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, errors: list<string>} */
|
||||
public function validateReset(mixed $password): array
|
||||
{
|
||||
return $this->validate($password);
|
||||
}
|
||||
|
||||
public function isValid(mixed $password): bool
|
||||
{
|
||||
return $this->validate($password)['valid'];
|
||||
}
|
||||
|
||||
private function isCommonPlaceholder(string $password): bool
|
||||
{
|
||||
$canonical = strtolower($password);
|
||||
$canonical = strtr($canonical, ['@' => 'a', '$' => 's', '0' => 'o']);
|
||||
$canonical = preg_replace('/[^a-z0-9]/', '', $canonical) ?? '';
|
||||
|
||||
return preg_match(
|
||||
'/^(?:password|changeme|welcome|admin|administrator|temporary|temppassword|qwerty|letmein)[0-9]*$/',
|
||||
$canonical,
|
||||
) === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
final class UserRecord
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const DISPLAY_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role_id',
|
||||
'role_name',
|
||||
'is_active',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
private readonly PasswordPolicy $passwordPolicy;
|
||||
|
||||
public function __construct(?PasswordPolicy $passwordPolicy = null)
|
||||
{
|
||||
$this->passwordPolicy = $passwordPolicy ?? new PasswordPolicy();
|
||||
}
|
||||
|
||||
/** @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed} */
|
||||
public function normalize(array $record): array
|
||||
{
|
||||
return [
|
||||
'name' => trim(is_scalar($record['name'] ?? null) ? (string) $record['name'] : ''),
|
||||
'email' => strtolower(trim(is_scalar($record['email'] ?? null) ? (string) $record['email'] : '')),
|
||||
'role_id' => $this->normalizeRoleId($record['role_id'] ?? null),
|
||||
'is_active' => $this->normalizeActive($record['is_active'] ?? $record['active'] ?? true),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed, valid: bool, errors: array<string, string>} */
|
||||
public function validate(array $record): array
|
||||
{
|
||||
$normalized = $this->normalize($record);
|
||||
$errors = [];
|
||||
|
||||
if ($normalized['name'] === '') {
|
||||
$errors['name'] = 'User name is required.';
|
||||
} elseif (mb_strlen($normalized['name']) > 120) {
|
||||
$errors['name'] = 'User name must be 120 characters or fewer.';
|
||||
}
|
||||
|
||||
if ($normalized['email'] === '') {
|
||||
$errors['email'] = 'Email address is required.';
|
||||
} elseif (mb_strlen($normalized['email']) > 190) {
|
||||
$errors['email'] = 'Email address must be 190 characters or fewer.';
|
||||
} elseif (filter_var($normalized['email'], FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['email'] = 'Email address must be valid.';
|
||||
}
|
||||
|
||||
if (!is_int($normalized['role_id']) || $normalized['role_id'] < 1) {
|
||||
$errors['role_id'] = 'Role must be a positive integer.';
|
||||
}
|
||||
|
||||
if (!is_bool($normalized['is_active'])) {
|
||||
$errors['is_active'] = 'Active flag must be boolean.';
|
||||
}
|
||||
|
||||
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a new user and its initial password without returning the
|
||||
* plaintext password in the result.
|
||||
*
|
||||
* @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed, valid: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function validateForCreate(array $record): array
|
||||
{
|
||||
$result = $this->validate($record);
|
||||
$password = $this->passwordPolicy->validateInitial($record['password'] ?? null);
|
||||
if (!$password['valid']) {
|
||||
$result['errors']['password'] = 'Password requires: ' . implode(', ', $password['errors']) . '.';
|
||||
$result['valid'] = false;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function display(array $record): array
|
||||
{
|
||||
$safe = [];
|
||||
foreach (self::DISPLAY_FIELDS as $field) {
|
||||
if (array_key_exists($field, $record)) {
|
||||
$safe[$field] = $record[$field];
|
||||
}
|
||||
}
|
||||
return $safe;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDisplay(array $record): array
|
||||
{
|
||||
return $this->display($record);
|
||||
}
|
||||
|
||||
private function normalizeRoleId(mixed $value): int|string|null
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && preg_match('/^[0-9]+$/', trim($value)) === 1) {
|
||||
return (int) trim($value);
|
||||
}
|
||||
return is_scalar($value) || $value === null ? $value : null;
|
||||
}
|
||||
|
||||
private function normalizeActive(mixed $value): mixed
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_int($value) && ($value === 0 || $value === 1)) {
|
||||
return $value === 1;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return match (strtolower(trim($value))) {
|
||||
'1', 'true', 'yes', 'on' => true,
|
||||
'0', 'false', 'no', 'off' => false,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user