big restructure
This commit is contained in:
307
lib/Service/User/UserAvatarService.php
Normal file
307
lib/Service/User/UserAvatarService.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\User;
|
||||
|
||||
class UserAvatarService
|
||||
{
|
||||
private const MAX_SIZE = 5242880; // 5 MB
|
||||
private const SIZES = [64, 128, 256];
|
||||
private const DEFAULT_SIZE = 128;
|
||||
|
||||
public static function isValidUuid(string $uuid): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
|
||||
}
|
||||
|
||||
public static function storageBase(): string
|
||||
{
|
||||
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
||||
return rtrim(APP_STORAGE_PATH, '/');
|
||||
}
|
||||
return rtrim(dirname(__DIR__, 3) . '/storage', '/');
|
||||
}
|
||||
|
||||
public static function userDir(string $uuid): string
|
||||
{
|
||||
return self::storageBase() . '/users/' . $uuid;
|
||||
}
|
||||
|
||||
public static function findAvatarPath(string $uuid, ?int $size = null): ?string
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
$dir = self::userDir($uuid);
|
||||
if (!is_dir($dir)) {
|
||||
return null;
|
||||
}
|
||||
if ($size) {
|
||||
$size = self::normalizeSize($size);
|
||||
$variant = self::findVariantPath($dir, $size);
|
||||
if ($variant) {
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
|
||||
if ($defaultVariant) {
|
||||
return $defaultVariant;
|
||||
}
|
||||
$original = self::findOriginalPath($dir);
|
||||
return $original ?: null;
|
||||
}
|
||||
|
||||
public static function hasAvatar(string $uuid): bool
|
||||
{
|
||||
$path = self::findAvatarPath($uuid);
|
||||
return $path ? is_file($path) : false;
|
||||
}
|
||||
|
||||
public static function delete(string $uuid): bool
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return false;
|
||||
}
|
||||
$dir = self::userDir($uuid);
|
||||
if (!is_dir($dir)) {
|
||||
return true;
|
||||
}
|
||||
$matches = array_merge(
|
||||
glob($dir . '/avatar-*.*') ?: [],
|
||||
glob($dir . '/avatar.*') ?: [],
|
||||
glob($dir . '/original.*') ?: []
|
||||
);
|
||||
foreach ($matches as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function saveUpload(string $uuid, array $file): array
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return ['ok' => false, 'error' => t('User not found')];
|
||||
}
|
||||
if (empty($file) || !isset($file['tmp_name'])) {
|
||||
return ['ok' => false, 'error' => t('No file uploaded')];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
if (($file['size'] ?? 0) > self::MAX_SIZE) {
|
||||
return ['ok' => false, 'error' => t('File is too large')];
|
||||
}
|
||||
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = self::detectMime($tmpPath);
|
||||
$isSvg = self::isSvgUpload($mime, $tmpPath);
|
||||
$ext = self::extensionForMime($mime, $isSvg);
|
||||
if (!$ext) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
if ($isSvg && !self::isSafeSvg($tmpPath)) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
|
||||
$dir = self::userDir($uuid);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
self::delete($uuid);
|
||||
$originalPath = $dir . '/original.' . $ext;
|
||||
if (!move_uploaded_file($tmpPath, $originalPath)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
|
||||
if (!$isSvg && self::canResize()) {
|
||||
foreach (self::SIZES as $size) {
|
||||
$target = $dir . '/avatar-' . $size . '.' . $variantExt;
|
||||
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
|
||||
}
|
||||
|
||||
public static function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
if (self::isSvgUpload($mime, $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (self::isSvgUpload('application/octet-stream', $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
|
||||
{
|
||||
if ($isSvg) {
|
||||
return 'svg';
|
||||
}
|
||||
$map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
return $map[$mime] ?? null;
|
||||
}
|
||||
|
||||
private static function isSvgUpload(string $mime, string $path): bool
|
||||
{
|
||||
if ($mime === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
$head = @file_get_contents($path, false, null, 0, 1024);
|
||||
if (!is_string($head) || $head === '') {
|
||||
return false;
|
||||
}
|
||||
return stripos($head, '<svg') !== false;
|
||||
}
|
||||
|
||||
private static function isSafeSvg(string $path): bool
|
||||
{
|
||||
$contents = @file_get_contents($path);
|
||||
if (!is_string($contents) || $contents === '') {
|
||||
return false;
|
||||
}
|
||||
$lower = strtolower($contents);
|
||||
if (strpos($lower, '<script') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'onload=') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'javascript:') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, '<foreignobject') !== false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function normalizeSize(int $size): int
|
||||
{
|
||||
if (in_array($size, self::SIZES, true)) {
|
||||
return $size;
|
||||
}
|
||||
return self::DEFAULT_SIZE;
|
||||
}
|
||||
|
||||
private static function findVariantPath(string $dir, int $size): ?string
|
||||
{
|
||||
$matches = glob($dir . '/avatar-' . $size . '.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function findOriginalPath(string $dir): ?string
|
||||
{
|
||||
$matches = glob($dir . '/original.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function canResize(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
|
||||
}
|
||||
|
||||
private static function createImageResource(string $path, string $mime)
|
||||
{
|
||||
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
|
||||
return imagecreatefromjpeg($path);
|
||||
}
|
||||
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
|
||||
return imagecreatefrompng($path);
|
||||
}
|
||||
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
|
||||
return imagecreatefromwebp($path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
|
||||
{
|
||||
$mime = self::detectMime($sourcePath);
|
||||
$src = self::createImageResource($sourcePath, $mime);
|
||||
if (!$src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$srcWidth = imagesx($src);
|
||||
$srcHeight = imagesy($src);
|
||||
if ($srcWidth === 0 || $srcHeight === 0) {
|
||||
imagedestroy($src);
|
||||
return false;
|
||||
}
|
||||
|
||||
$scale = min($width / $srcWidth, $height / $srcHeight);
|
||||
$dstWidth = (int) round($srcWidth * $scale);
|
||||
$dstHeight = (int) round($srcHeight * $scale);
|
||||
if ($dstWidth < 1) $dstWidth = 1;
|
||||
if ($dstHeight < 1) $dstHeight = 1;
|
||||
|
||||
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
|
||||
if ($ext === 'png' || $ext === 'webp') {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$dst,
|
||||
$src,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$dstWidth,
|
||||
$dstHeight,
|
||||
$srcWidth,
|
||||
$srcHeight
|
||||
);
|
||||
|
||||
$saved = false;
|
||||
if ($ext === 'webp' && function_exists('imagewebp')) {
|
||||
$saved = imagewebp($dst, $targetPath, 82);
|
||||
} elseif ($ext === 'png' && function_exists('imagepng')) {
|
||||
$saved = imagepng($dst, $targetPath, 6);
|
||||
} else {
|
||||
$saved = imagejpeg($dst, $targetPath, 85);
|
||||
}
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved) {
|
||||
@chmod($targetPath, 0644);
|
||||
}
|
||||
return $saved;
|
||||
}
|
||||
}
|
||||
805
lib/Service/User/UserService.php
Normal file
805
lib/Service/User/UserService.php
Normal file
@@ -0,0 +1,805 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\User;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Repository\Org\DepartmentRepository;
|
||||
use MintyPHP\Repository\Access\RoleRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Repository\User\UserRepository;
|
||||
use MintyPHP\Repository\Org\UserDepartmentRepository;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantDepartmentRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
|
||||
class UserService
|
||||
{
|
||||
private const PASSWORD_MIN_LENGTH = 12;
|
||||
public static function list(): array
|
||||
{
|
||||
return UserRepository::list();
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) {
|
||||
unset($options['tenantUserId']);
|
||||
}
|
||||
}
|
||||
return UserRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return UserRepository::findByUuid($uuid);
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return UserRepository::find($id);
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
return UserRepository::findByEmail($email);
|
||||
}
|
||||
|
||||
public static function setLocale(int $userId, string $locale): bool
|
||||
{
|
||||
return UserRepository::setLocale($userId, $locale);
|
||||
}
|
||||
|
||||
public static function setTheme(int $userId, string $theme): bool
|
||||
{
|
||||
$theme = self::normalizeTheme($theme);
|
||||
return UserRepository::setTheme($userId, $theme);
|
||||
}
|
||||
|
||||
public static function deleteByUuid(string $uuid, int $currentUserId = 0): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByUuid($uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
if ($currentUserId && $currentUserId === $userId) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 400,
|
||||
'error' => 'self_delete',
|
||||
'message' => t('You cannot delete your own account'),
|
||||
];
|
||||
}
|
||||
|
||||
$deleted = UserRepository::delete($userId);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'user' => $user];
|
||||
}
|
||||
|
||||
public static function deleteByUuids(array $uuids, int $currentUserId = 0): array
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'no_selection'];
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
$uuids = self::filterUuidsByTenantScope($uuids, $currentUserId);
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'permission_denied'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
$currentUser = UserRepository::find($currentUserId);
|
||||
$currentUuid = $currentUser['uuid'] ?? '';
|
||||
if ($currentUuid !== '') {
|
||||
$uuids = array_values(array_filter($uuids, static fn ($uuid) => $uuid !== $currentUuid));
|
||||
}
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'self_delete'];
|
||||
}
|
||||
}
|
||||
|
||||
$deleted = UserRepository::deleteByUuids($uuids);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'count' => count($uuids)];
|
||||
}
|
||||
|
||||
public static function createFromAdmin(array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
|
||||
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
|
||||
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
|
||||
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
|
||||
if (array_key_exists('active', $input)) {
|
||||
$form['active'] = isset($input['active']) ? 1 : 0;
|
||||
} else {
|
||||
$form['active'] = 1;
|
||||
}
|
||||
$password = (string) ($input['password'] ?? '');
|
||||
$password2 = (string) ($input['password2'] ?? '');
|
||||
|
||||
$errors = self::validateBase($form);
|
||||
$errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email']));
|
||||
|
||||
$tenantIds = $input['tenant_ids'] ?? [];
|
||||
if (!is_array($tenantIds)) {
|
||||
$tenantIds = [$tenantIds];
|
||||
}
|
||||
$tenantIds = self::normalizeTenantIds($tenantIds);
|
||||
if (!$tenantIds) {
|
||||
$defaultTenantId = SettingService::getDefaultTenantId();
|
||||
if ($defaultTenantId) {
|
||||
$tenantIds = [$defaultTenantId];
|
||||
}
|
||||
}
|
||||
[$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds);
|
||||
$errors = array_merge($errors, $primaryErrors);
|
||||
|
||||
if ($errors) {
|
||||
$form['primary_tenant_id'] = $primaryTenantId;
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$activeChangedAt = gmdate('Y-m-d H:i:s');
|
||||
$created = UserRepository::create([
|
||||
'first_name' => $form['first_name'],
|
||||
'last_name' => $form['last_name'],
|
||||
'email' => $form['email'],
|
||||
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
|
||||
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
|
||||
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
|
||||
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
|
||||
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
|
||||
'address' => $form['address'] !== '' ? $form['address'] : null,
|
||||
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
|
||||
'city' => $form['city'] !== '' ? $form['city'] : null,
|
||||
'country' => $form['country'] !== '' ? $form['country'] : null,
|
||||
'region' => $form['region'] !== '' ? $form['region'] : null,
|
||||
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
|
||||
'password' => $password,
|
||||
'locale' => $form['locale'],
|
||||
'totp_secret' => $form['totp_secret'],
|
||||
'theme' => $form['theme'],
|
||||
'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null,
|
||||
'active' => $form['active'],
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
'active_changed_at' => $activeChangedAt,
|
||||
'active_changed_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$created) {
|
||||
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
||||
}
|
||||
|
||||
$createdUser = UserRepository::findByEmail($form['email']);
|
||||
$uuid = $createdUser['uuid'] ?? null;
|
||||
$userId = (int) ($createdUser['id'] ?? 0);
|
||||
|
||||
if ($userId > 0 && $tenantIds) {
|
||||
self::syncTenants($userId, $tenantIds);
|
||||
}
|
||||
|
||||
$roleIds = self::normalizeIdInput($input['role_ids'] ?? []);
|
||||
if (!$roleIds) {
|
||||
$defaultRoleId = SettingService::getDefaultRoleId();
|
||||
if ($defaultRoleId) {
|
||||
$roleIds = [$defaultRoleId];
|
||||
}
|
||||
}
|
||||
if ($userId > 0 && $roleIds) {
|
||||
self::syncRoles($userId, $roleIds);
|
||||
}
|
||||
|
||||
$departmentIds = self::normalizeIdInput($input['department_ids'] ?? []);
|
||||
if (!$departmentIds) {
|
||||
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
|
||||
if ($defaultDepartmentId) {
|
||||
$departmentIds = [$defaultDepartmentId];
|
||||
}
|
||||
}
|
||||
if ($userId > 0 && $departmentIds) {
|
||||
self::syncDepartments($userId, $departmentIds);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
|
||||
}
|
||||
|
||||
public static function updateFromAdmin(int $userId, array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
|
||||
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
|
||||
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
|
||||
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
|
||||
$tenantIdsProvided = array_key_exists('tenant_ids', $input);
|
||||
$primaryProvided = array_key_exists('primary_tenant_id', $input);
|
||||
if ($tenantIdsProvided) {
|
||||
$tenantIds = $input['tenant_ids'] ?? [];
|
||||
if (!is_array($tenantIds)) {
|
||||
$tenantIds = [$tenantIds];
|
||||
}
|
||||
$tenantIds = self::normalizeTenantIds($tenantIds);
|
||||
} else {
|
||||
$tenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
|
||||
}
|
||||
if (array_key_exists('active', $input)) {
|
||||
$form['active'] = isset($input['active']) ? 1 : 0;
|
||||
} else {
|
||||
$existing = UserRepository::find($userId);
|
||||
$form['active'] = (int) ($existing['active'] ?? 1);
|
||||
}
|
||||
$password = (string) ($input['password'] ?? '');
|
||||
$password2 = (string) ($input['password2'] ?? '');
|
||||
|
||||
$errors = self::validateBase($form, $userId);
|
||||
if ($userId === $currentUserId && !$form['active']) {
|
||||
$errors[] = t('You cannot deactivate your own account');
|
||||
}
|
||||
$errors = array_merge($errors, self::validatePassword($password, $password2, false, $form['email']));
|
||||
if ($tenantIds && ($tenantIdsProvided || $primaryProvided)) {
|
||||
[$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds);
|
||||
$errors = array_merge($errors, $primaryErrors);
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
if ($tenantIdsProvided || $primaryProvided) {
|
||||
$form['primary_tenant_id'] = $primaryTenantId;
|
||||
}
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
if ($tenantIdsProvided || $primaryProvided) {
|
||||
$form['primary_tenant_id'] = $primaryTenantId > 0 ? $primaryTenantId : null;
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'first_name' => $form['first_name'],
|
||||
'last_name' => $form['last_name'],
|
||||
'email' => $form['email'],
|
||||
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
|
||||
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
|
||||
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
|
||||
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
|
||||
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
|
||||
'address' => $form['address'] !== '' ? $form['address'] : null,
|
||||
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
|
||||
'city' => $form['city'] !== '' ? $form['city'] : null,
|
||||
'country' => $form['country'] !== '' ? $form['country'] : null,
|
||||
'region' => $form['region'] !== '' ? $form['region'] : null,
|
||||
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
|
||||
'totp_secret' => $form['totp_secret'],
|
||||
'theme' => $form['theme'],
|
||||
'locale' => $form['locale'],
|
||||
'active' => $form['active'],
|
||||
'password' => $password,
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
];
|
||||
if ($tenantIdsProvided || $primaryProvided) {
|
||||
$updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null;
|
||||
}
|
||||
|
||||
if ((int) ($existing['active'] ?? 1) !== (int) $form['active']) {
|
||||
$updateData['active_changed_at'] = gmdate('Y-m-d H:i:s');
|
||||
$updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
|
||||
}
|
||||
|
||||
$updated = UserRepository::update($userId, $updateData);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form];
|
||||
}
|
||||
|
||||
public static function setActiveByUuid(string $uuid, bool $active, int $currentUserId = 0): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByUuid($uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
if (!$active && $currentUserId && $currentUserId === $userId) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 400,
|
||||
'error' => 'self_deactivate',
|
||||
'message' => t('You cannot deactivate your own account'),
|
||||
];
|
||||
}
|
||||
|
||||
$updated = UserRepository::setActive($userId, $active, $currentUserId > 0 ? $currentUserId : null);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'user' => $user];
|
||||
}
|
||||
|
||||
public static function setActiveByUuids(array $uuids, bool $active, int $currentUserId = 0): array
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'no_selection'];
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
$uuids = self::filterUuidsByTenantScope($uuids, $currentUserId);
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'permission_denied'];
|
||||
}
|
||||
}
|
||||
|
||||
$updated = UserRepository::setActiveByUuids($uuids, $active, $currentUserId > 0 ? $currentUserId : null);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'count' => count($uuids)];
|
||||
}
|
||||
|
||||
private static function filterUuidsByTenantScope(array $uuids, int $currentUserId): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($uuids as $uuid) {
|
||||
$user = UserRepository::findByUuid((string) $uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
continue;
|
||||
}
|
||||
$userId = (int) $user['id'];
|
||||
if ($userId === $currentUserId) {
|
||||
$allowed[] = (string) $uuid;
|
||||
continue;
|
||||
}
|
||||
if (TenantScopeService::canAccess('users', $userId, $currentUserId)) {
|
||||
$allowed[] = (string) $uuid;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($allowed));
|
||||
}
|
||||
|
||||
public static function register(array $input): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$password = (string) ($input['password'] ?? '');
|
||||
$password2 = (string) ($input['password2'] ?? '');
|
||||
|
||||
$errors = self::validateBase($form);
|
||||
$errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email']));
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'error' => $errors[0]];
|
||||
}
|
||||
|
||||
$defaultTheme = SettingService::getAppTheme();
|
||||
$defaultTenantId = SettingService::getDefaultTenantId();
|
||||
$activeChangedAt = gmdate('Y-m-d H:i:s');
|
||||
$created = UserRepository::create([
|
||||
'first_name' => $form['first_name'],
|
||||
'last_name' => $form['last_name'],
|
||||
'email' => $form['email'],
|
||||
'password' => $password,
|
||||
'locale' => I18n::$locale,
|
||||
'totp_secret' => '',
|
||||
'theme' => self::normalizeTheme($defaultTheme ?? 'light'),
|
||||
'primary_tenant_id' => $defaultTenantId ?: null,
|
||||
'active' => 1,
|
||||
'created_by' => null,
|
||||
'active_changed_at' => $activeChangedAt,
|
||||
'active_changed_by' => null,
|
||||
]);
|
||||
|
||||
if (!$created) {
|
||||
return ['ok' => false, 'error' => t('User can not be registered')];
|
||||
}
|
||||
|
||||
$createdUser = UserRepository::findByEmail($form['email']);
|
||||
$userId = (int) ($createdUser['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
if ($defaultTenantId) {
|
||||
self::syncTenants($userId, [$defaultTenantId]);
|
||||
}
|
||||
$defaultRoleId = SettingService::getDefaultRoleId();
|
||||
if ($defaultRoleId) {
|
||||
self::syncRoles($userId, [$defaultRoleId]);
|
||||
}
|
||||
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
|
||||
if ($defaultDepartmentId) {
|
||||
self::syncDepartments($userId, [$defaultDepartmentId]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function syncTenants(int $userId, array $tenantIds): bool
|
||||
{
|
||||
$ids = self::normalizeTenantIds($tenantIds);
|
||||
return UserTenantRepository::replaceForUser($userId, $ids);
|
||||
}
|
||||
|
||||
public static function syncRoles(int $userId, array $roleIds): bool
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $roleIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$validIds = RoleRepository::listActiveIds();
|
||||
if ($validIds) {
|
||||
$validMap = array_fill_keys($validIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
|
||||
}
|
||||
$result = UserRoleRepository::replaceForUser($userId, $ids);
|
||||
PermissionService::clearUserCache($userId);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function syncDepartments(int $userId, array $departmentIds): bool
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $departmentIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
|
||||
if (!$userTenantIds) {
|
||||
$ids = [];
|
||||
} else {
|
||||
$allowedIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds($userTenantIds);
|
||||
if ($allowedIds) {
|
||||
$allowedMap = array_fill_keys($allowedIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id])));
|
||||
} else {
|
||||
$ids = [];
|
||||
}
|
||||
}
|
||||
return UserDepartmentRepository::replaceForUser($userId, $ids);
|
||||
}
|
||||
|
||||
private static function sanitizeBase(array $input): array
|
||||
{
|
||||
return [
|
||||
'first_name' => trim((string) ($input['first_name'] ?? '')),
|
||||
'last_name' => trim((string) ($input['last_name'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'profile_description' => trim((string) ($input['profile_description'] ?? '')),
|
||||
'job_title' => trim((string) ($input['job_title'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'mobile' => trim((string) ($input['mobile'] ?? '')),
|
||||
'short_dial' => trim((string) ($input['short_dial'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'postal_code' => trim((string) ($input['postal_code'] ?? '')),
|
||||
'city' => trim((string) ($input['city'] ?? '')),
|
||||
'country' => trim((string) ($input['country'] ?? '')),
|
||||
'region' => trim((string) ($input['region'] ?? '')),
|
||||
'hire_date' => trim((string) ($input['hire_date'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizeIdInput($value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
$value = [$value];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $value)));
|
||||
return array_values(array_filter($ids, static fn ($id) => $id > 0));
|
||||
}
|
||||
|
||||
private static function normalizeTenantIds(array $tenantIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$validIds = TenantRepository::listIds();
|
||||
if ($validIds) {
|
||||
$validMap = array_fill_keys($validIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private static function normalizeTheme($value): string
|
||||
{
|
||||
$theme = strtolower(trim((string) $value));
|
||||
$themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light', 'dark' => 'Dark'];
|
||||
if ($theme === '' || !isset($themes[$theme])) {
|
||||
return function_exists('appDefaultTheme') ? appDefaultTheme() : 'light';
|
||||
}
|
||||
return $theme;
|
||||
}
|
||||
|
||||
private static function normalizeLocale($value): string
|
||||
{
|
||||
$locale = strtolower(trim((string) $value));
|
||||
$available = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale];
|
||||
if ($locale === '' || !in_array($locale, $available, true)) {
|
||||
return I18n::$locale ?? I18n::$defaultLocale;
|
||||
}
|
||||
return $locale;
|
||||
}
|
||||
|
||||
private static function validateBase(array $form, ?int $excludeId = null): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['first_name'] === '') {
|
||||
$errors[] = t('First name cannot be empty');
|
||||
}
|
||||
if ($form['last_name'] === '') {
|
||||
$errors[] = t('Last name cannot be empty');
|
||||
}
|
||||
if ($form['email'] === '') {
|
||||
$errors[] = t('Email cannot be empty');
|
||||
} elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = t('Email is not valid');
|
||||
} else {
|
||||
$existing = UserRepository::findByEmail($form['email']);
|
||||
if ($existing && (!isset($existing['id']) || (int) $existing['id'] !== (int) $excludeId)) {
|
||||
$errors[] = t('Email is already taken');
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private static function normalizePrimaryTenant(int $primaryTenantId, array $tenantIds): array
|
||||
{
|
||||
if (!$tenantIds) {
|
||||
return [0, []];
|
||||
}
|
||||
if ($primaryTenantId > 0 && !in_array($primaryTenantId, $tenantIds, true)) {
|
||||
return [$primaryTenantId, [t('Primary tenant must be one of the assigned tenants')]];
|
||||
}
|
||||
if ($primaryTenantId === 0 && count($tenantIds) > 1) {
|
||||
return [0, [t('Please select a primary tenant')]];
|
||||
}
|
||||
if ($primaryTenantId === 0 && count($tenantIds) === 1) {
|
||||
return [(int) $tenantIds[0], []];
|
||||
}
|
||||
return [$primaryTenantId, []];
|
||||
}
|
||||
|
||||
private static function validatePassword(
|
||||
string $password,
|
||||
string $password2,
|
||||
bool $required,
|
||||
?string $email
|
||||
): array {
|
||||
return self::validatePasswordStrength($password, $password2, $required, $email);
|
||||
}
|
||||
|
||||
public static function passwordHints(): array
|
||||
{
|
||||
return [
|
||||
['rule' => 'min', 'text' => t('At least %d characters', self::PASSWORD_MIN_LENGTH)],
|
||||
['rule' => 'upper', 'text' => t('At least one uppercase letter')],
|
||||
['rule' => 'lower', 'text' => t('At least one lowercase letter')],
|
||||
['rule' => 'number', 'text' => t('At least one number')],
|
||||
['rule' => 'symbol', 'text' => t('At least one symbol')],
|
||||
['rule' => 'email', 'text' => t('Must not contain your email')],
|
||||
];
|
||||
}
|
||||
|
||||
public static function passwordMinLength(): int
|
||||
{
|
||||
return self::PASSWORD_MIN_LENGTH;
|
||||
}
|
||||
|
||||
public static function resetPassword(int $userId, string $password, string $password2): array
|
||||
{
|
||||
$errors = self::validatePasswordStrength($password, $password2, true, '');
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$updated = UserRepository::setPassword($userId, $password);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Password can not be updated')]];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private static function validatePasswordStrength(
|
||||
string $password,
|
||||
string $password2,
|
||||
bool $required,
|
||||
?string $email
|
||||
): array {
|
||||
$errors = [];
|
||||
if ($required && $password === '') {
|
||||
$errors[] = t('Password cannot be empty');
|
||||
return $errors;
|
||||
}
|
||||
if ($password !== '' && $password !== $password2) {
|
||||
$errors[] = t('Passwords must match');
|
||||
}
|
||||
if ($password === '') {
|
||||
return $errors;
|
||||
}
|
||||
if (strlen($password) < self::PASSWORD_MIN_LENGTH) {
|
||||
$errors[] = t('Password must be at least %d characters', self::PASSWORD_MIN_LENGTH);
|
||||
}
|
||||
if (!preg_match('/[A-Z]/', $password)) {
|
||||
$errors[] = t('Password must include an uppercase letter');
|
||||
}
|
||||
if (!preg_match('/[a-z]/', $password)) {
|
||||
$errors[] = t('Password must include a lowercase letter');
|
||||
}
|
||||
if (!preg_match('/\\d/', $password)) {
|
||||
$errors[] = t('Password must include a number');
|
||||
}
|
||||
if (!preg_match('/[^a-zA-Z0-9]/', $password)) {
|
||||
$errors[] = t('Password must include a symbol');
|
||||
}
|
||||
if ($email) {
|
||||
$email = trim((string) $email);
|
||||
if ($email !== '' && stripos($password, $email) !== false) {
|
||||
$errors[] = t('Password must not contain your email');
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tenant ID for a user (with fallback to primary tenant)
|
||||
*/
|
||||
public static function getCurrentTenantId(int $userId): ?int
|
||||
{
|
||||
$user = UserRepository::find($userId);
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeTenantIds = TenantScopeService::getUserTenantIds($userId);
|
||||
if (!$activeTenantIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentTenantId = (int) ($user['current_tenant_id'] ?? 0);
|
||||
if ($currentTenantId > 0 && in_array($currentTenantId, $activeTenantIds, true)) {
|
||||
return $currentTenantId;
|
||||
}
|
||||
|
||||
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
|
||||
if ($primaryTenantId > 0 && in_array($primaryTenantId, $activeTenantIds, true)) {
|
||||
return $primaryTenantId;
|
||||
}
|
||||
|
||||
return $activeTenantIds[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tenant data for a user
|
||||
*/
|
||||
public static function getCurrentTenant(int $userId): ?array
|
||||
{
|
||||
$currentTenantId = self::getCurrentTenantId($userId);
|
||||
if (!$currentTenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantRepository::find($currentTenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tenants the user has access to
|
||||
*/
|
||||
public static function getAvailableTenants(int $userId): array
|
||||
{
|
||||
$tenantIds = TenantScopeService::getUserTenantIds($userId);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenants = [];
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$tenant = TenantRepository::find($tenantId);
|
||||
if ($tenant && (($tenant['status'] ?? 'active') === 'active')) {
|
||||
$tenants[] = $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by description
|
||||
usort($tenants, static function ($a, $b) {
|
||||
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
|
||||
});
|
||||
|
||||
return $tenants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available departments grouped by tenant for a user.
|
||||
* Returns an array of groups: ['tenant' => [...], 'departments' => [...]]
|
||||
*/
|
||||
public static function getAvailableDepartmentsByTenant(int $userId): array
|
||||
{
|
||||
$tenants = self::getAvailableTenants($userId);
|
||||
if (!$tenants) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($tenants as $tenant) {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$departmentIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds([$tenantId]);
|
||||
$departments = $departmentIds ? DepartmentRepository::listByIds($departmentIds) : [];
|
||||
usort($departments, static function ($a, $b) {
|
||||
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
|
||||
});
|
||||
$departments = array_map(static function (array $department): array {
|
||||
return [
|
||||
'id' => (int) ($department['id'] ?? 0),
|
||||
'uuid' => $department['uuid'] ?? '',
|
||||
'description' => $department['description'] ?? '',
|
||||
];
|
||||
}, $departments);
|
||||
|
||||
$groups[] = [
|
||||
'tenant' => [
|
||||
'id' => (int) ($tenant['id'] ?? 0),
|
||||
'uuid' => $tenant['uuid'] ?? '',
|
||||
'description' => $tenant['description'] ?? '',
|
||||
],
|
||||
'departments' => $departments,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current tenant for a user (with validation)
|
||||
*/
|
||||
public static function setCurrentTenant(int $userId, int $tenantId): array
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_tenant'];
|
||||
}
|
||||
|
||||
// Verify user has access to this tenant
|
||||
$userTenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
|
||||
if (!in_array($tenantId, $userTenantIds, true)) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_assigned'];
|
||||
}
|
||||
|
||||
// Verify tenant exists
|
||||
$tenant = TenantRepository::find($tenantId);
|
||||
if (!$tenant) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_found'];
|
||||
}
|
||||
if (($tenant['status'] ?? 'active') !== 'active') {
|
||||
return ['ok' => false, 'error' => 'tenant_inactive'];
|
||||
}
|
||||
|
||||
// Update current_tenant_id
|
||||
$updated = UserRepository::setCurrentTenant($userId, $tenantId);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tenant' => $tenant];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user