instances added god may help

This commit is contained in:
2026-02-23 12:58:19 +01:00
parent 25370a1a55
commit 99db252f60
290 changed files with 9858 additions and 4914 deletions

View File

@@ -6,7 +6,7 @@ use Dompdf\Dompdf;
use Dompdf\Options;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use Throwable;
use ZipArchive;
@@ -168,12 +168,13 @@ class UserAccessPdfService
$path = '';
$mime = '';
$logoService = (new BrandingServicesFactory())->createBrandingLogoService();
// Prefer tenant/app branding logo first.
if (class_exists(BrandingLogoService::class) && BrandingLogoService::hasLogo()) {
$logoPath = BrandingLogoService::findLogoPath(128);
if ($logoService->hasLogo()) {
$logoPath = $logoService->findLogoPath(128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = BrandingLogoService::detectMime($logoPath);
$mime = $logoService->detectMime($logoPath);
}
}

View File

@@ -5,8 +5,7 @@ namespace MintyPHP\Service\User;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Service\Auth\TenantSsoService;
use MintyPHP\Service\Auth\AuthServicesFactory;
class UserAccessTemplateService
{
@@ -63,6 +62,7 @@ class UserAccessTemplateService
private static function resolveTenantLoginUrl(array $user, string $locale): string
{
$tenantSsoService = (new AuthServicesFactory())->createTenantSsoService();
$tenantIds = [];
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
$currentTenantId = (int) ($user['current_tenant_id'] ?? 0);
@@ -75,18 +75,21 @@ class UserAccessTemplateService
$userId = (int) ($user['id'] ?? 0);
if (!$tenantIds && $userId > 0) {
$tenantIds = array_values(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId)));
$tenantIds = array_values(array_map(
'intval',
(new UserServicesFactory())->createUserTenantRepository()->listTenantIdsByUserId($userId)
));
}
foreach ($tenantIds as $tenantId) {
if ($tenantId <= 0) {
continue;
}
$tenant = TenantRepository::find($tenantId);
$tenant = (new TenantRepository())->find($tenantId);
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
continue;
}
$tenantSlug = TenantSsoService::tenantLoginSlug($tenant);
$tenantSlug = $tenantSsoService->tenantLoginSlug($tenant);
if ($tenantSlug !== '') {
return appUrl(Request::withLocale('login?tenant=' . rawurlencode($tenantSlug), $locale));
}

View File

@@ -0,0 +1,550 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\I18n;
use MintyPHP\Repository\User\UserListQueryRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserWriteRepository;
class UserAccountService
{
public function __construct(
private readonly UserReadRepository $userReadRepository,
private readonly UserWriteRepository $userWriteRepository,
private readonly UserListQueryRepository $userListQueryRepository,
private readonly UserAssignmentService $userAssignmentService,
private readonly UserPasswordService $userPasswordService,
private readonly UserSettingsGateway $settingsGateway,
private readonly UserScopeGateway $scopeGateway
) {
}
public function listPaged(array $options): array
{
if (!empty($options['tenantUserId'])) {
$tenantUserId = (int) $options['tenantUserId'];
if ($tenantUserId > 0 && $this->scopeGateway->hasGlobalAccess($tenantUserId)) {
unset($options['tenantUserId']);
}
}
return $this->userListQueryRepository->listPaged($options);
}
public function findByUuid(string $uuid): ?array
{
return $this->userReadRepository->findByUuid($uuid);
}
public function findById(int $id): ?array
{
return $this->userReadRepository->find($id);
}
public function findByEmail(string $email): ?array
{
return $this->userReadRepository->findByEmail($email);
}
public function setLocale(int $userId, string $locale): bool
{
return $this->userWriteRepository->setLocale($userId, $locale);
}
public function setTheme(int $userId, string $theme): bool
{
$theme = $this->normalizeTheme($theme);
return $this->userWriteRepository->setTheme($userId, $theme);
}
public function deleteByUuid(string $uuid, int $currentUserId = 0): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$user = $this->userReadRepository->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 = $this->userWriteRepository->delete($userId);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'user' => $user];
}
public 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 = $this->filterUuidsByTenantScope($uuids, $currentUserId);
if (!$uuids) {
return ['ok' => false, 'error' => 'permission_denied'];
}
}
if ($currentUserId > 0) {
$currentUser = $this->userReadRepository->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 = $this->userWriteRepository->deleteByUuids($uuids);
if (!$deleted) {
return ['ok' => false, 'error' => 'delete_failed'];
}
return ['ok' => true, 'count' => count($uuids)];
}
public function createFromAdmin(array $input, int $currentUserId = 0): array
{
$form = $this->sanitizeBase($input);
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
$form['theme'] = $this->normalizeTheme($input['theme'] ?? null);
$form['locale'] = $this->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 = $this->validateBase($form);
$errors = array_merge($errors, $this->userPasswordService->validatePassword($password, $password2, true, $form['email']));
$tenantIds = $input['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
$tenantIds = [$tenantIds];
}
$tenantIds = $this->normalizeTenantIds($tenantIds);
if (!$tenantIds) {
$defaultTenantId = $this->settingsGateway->getDefaultTenantId();
if ($defaultTenantId) {
$tenantIds = [$defaultTenantId];
}
}
[$primaryTenantId, $primaryErrors] = $this->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 = $this->userWriteRepository->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 = $this->userReadRepository->findByEmail($form['email']);
$uuid = $createdUser['uuid'] ?? null;
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0 && $tenantIds) {
$this->userAssignmentService->syncTenants($userId, $tenantIds);
}
$roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []);
if (!$roleIds) {
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$roleIds = [$defaultRoleId];
}
}
if ($userId > 0 && $roleIds) {
$this->userAssignmentService->syncRoles($userId, $roleIds);
}
$departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []);
if (!$departmentIds) {
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$departmentIds = [$defaultDepartmentId];
}
}
if ($userId > 0 && $departmentIds) {
$this->userAssignmentService->syncDepartments($userId, $departmentIds);
}
return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
}
public function updateFromAdmin(int $userId, array $input, int $currentUserId = 0): array
{
$form = $this->sanitizeBase($input);
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
$form['locale'] = $this->normalizeLocale($input['locale'] ?? null);
$themeProvided = array_key_exists('theme', $input);
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
$tenantIdsProvided = array_key_exists('tenant_ids', $input);
$primaryProvided = array_key_exists('primary_tenant_id', $input);
$existing = $this->userReadRepository->find($userId) ?? [];
if ($themeProvided) {
$form['theme'] = $this->normalizeTheme($input['theme'] ?? null);
} else {
$form['theme'] = $this->normalizeTheme($existing['theme'] ?? null);
}
if ($tenantIdsProvided) {
$tenantIds = $input['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
$tenantIds = [$tenantIds];
}
$tenantIds = $this->normalizeTenantIds($tenantIds);
} else {
$tenantIds = $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? [];
$tenantIds = array_values(array_map(static fn (array $tenant): int => (int) ($tenant['id'] ?? 0), $tenantIds));
$tenantIds = array_values(array_filter($tenantIds, static fn (int $id): bool => $id > 0));
}
if (array_key_exists('active', $input)) {
$form['active'] = isset($input['active']) ? 1 : 0;
} else {
$form['active'] = (int) ($existing['active'] ?? 1);
}
$password = (string) ($input['password'] ?? '');
$password2 = (string) ($input['password2'] ?? '');
$errors = $this->validateBase($form, $userId);
if ($userId === $currentUserId && !$form['active']) {
$errors[] = t('You cannot deactivate your own account');
}
$errors = array_merge($errors, $this->userPasswordService->validatePassword($password, $password2, false, $form['email']));
if ($tenantIds && ($tenantIdsProvided || $primaryProvided)) {
[$primaryTenantId, $primaryErrors] = $this->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'],
'locale' => $form['locale'],
'active' => $form['active'],
'password' => $password,
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
];
if ($themeProvided) {
$updateData['theme'] = $form['theme'];
}
if ($tenantIdsProvided || $primaryProvided) {
$updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null;
}
$activeChanged = (int) ($existing['active'] ?? 1) !== (int) $form['active'];
if ($activeChanged) {
$updateData['active_changed_at'] = gmdate('Y-m-d H:i:s');
$updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
}
$updated = $this->userWriteRepository->update($userId, $updateData);
if (!$updated) {
return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form];
}
if ($activeChanged) {
$this->userAssignmentService->bumpAuthzVersion($userId);
}
return ['ok' => true, 'form' => $form];
}
public function setActiveByUuid(string $uuid, bool $active, int $currentUserId = 0): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$user = $this->userReadRepository->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 = $this->userWriteRepository->setActive($userId, $active, $currentUserId > 0 ? $currentUserId : null);
if (!$updated) {
return ['ok' => false, 'status' => 500, 'error' => 'update_failed'];
}
$this->userAssignmentService->bumpAuthzVersion($userId);
return ['ok' => true, 'user' => $user];
}
public 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 = $this->filterUuidsByTenantScope($uuids, $currentUserId);
if (!$uuids) {
return ['ok' => false, 'error' => 'permission_denied'];
}
}
$updated = $this->userWriteRepository->setActiveByUuids($uuids, $active, $currentUserId > 0 ? $currentUserId : null);
if (!$updated) {
return ['ok' => false, 'error' => 'update_failed'];
}
$userIds = [];
foreach ($uuids as $uuid) {
$user = $this->userReadRepository->findByUuid((string) $uuid);
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0) {
$userIds[] = $userId;
}
}
if ($userIds) {
$this->userWriteRepository->bumpAuthzVersionByUserIds($userIds);
}
return ['ok' => true, 'count' => count($uuids)];
}
public function register(array $input): array
{
$form = $this->sanitizeBase($input);
$password = (string) ($input['password'] ?? '');
$password2 = (string) ($input['password2'] ?? '');
$errors = $this->validateBase($form);
$errors = array_merge($errors, $this->userPasswordService->validatePassword($password, $password2, true, $form['email']));
if ($errors) {
return ['ok' => false, 'error' => $errors[0]];
}
$defaultTheme = $this->settingsGateway->getAppTheme();
$defaultTenantId = $this->settingsGateway->getDefaultTenantId();
$activeChangedAt = gmdate('Y-m-d H:i:s');
$created = $this->userWriteRepository->create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'password' => $password,
'locale' => I18n::$locale,
'totp_secret' => '',
'theme' => $this->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 = $this->userReadRepository->findByEmail($form['email']);
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0) {
if ($defaultTenantId) {
$this->userAssignmentService->syncTenants($userId, [$defaultTenantId]);
}
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$this->userAssignmentService->syncRoles($userId, [$defaultRoleId]);
}
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]);
}
}
return ['ok' => true];
}
private function filterUuidsByTenantScope(array $uuids, int $currentUserId): array
{
$allowed = [];
foreach ($uuids as $uuid) {
$user = $this->userReadRepository->findByUuid((string) $uuid);
if (!$user || !isset($user['id'])) {
continue;
}
$userId = (int) $user['id'];
if ($userId === $currentUserId) {
$allowed[] = (string) $uuid;
continue;
}
if ($this->scopeGateway->canAccess('users', $userId, $currentUserId)) {
$allowed[] = (string) $uuid;
}
}
return array_values(array_unique($allowed));
}
private 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'] ?? '')),
];
}
private 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 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 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 = $this->userReadRepository->findByEmail($form['email']);
if ($existing && (!isset($existing['id']) || (int) $existing['id'] !== (int) $excludeId)) {
$errors[] = t('Email is already taken');
}
}
return $errors;
}
private 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 function normalizeTenantIds(array $tenantIds): array
{
return $this->userAssignmentService->normalizeTenantIds($tenantIds);
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Repository\Org\UserDepartmentRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserWriteRepository;
class UserAssignmentService
{
public function __construct(
private readonly UserWriteRepository $userWriteRepository,
private readonly UserTenantRepository $userTenantRepository,
private readonly UserRoleRepository $userRoleRepository,
private readonly UserDepartmentRepository $userDepartmentRepository,
private readonly UserDirectoryGateway $directoryGateway,
private readonly UserPermissionGateway $permissionGateway
) {
}
public function buildAssignmentsForUser(int $userId): array
{
if ($userId <= 0) {
return [
'tenants' => [],
'departments' => [],
'roles' => [],
];
}
$tenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId);
$departmentIds = $this->userDepartmentRepository->listDepartmentIdsByUserId($userId);
$roleIds = $this->userRoleRepository->listRoleIdsByUserId($userId);
$tenantsById = [];
foreach ($this->directoryGateway->listTenantsByIds($tenantIds) as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantsById[$tenantId] = [
'id' => $tenantId,
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
'status' => (string) ($tenant['status'] ?? ''),
];
}
$departmentsById = [];
foreach ($this->directoryGateway->listDepartmentsByIds($departmentIds, true) as $department) {
$departmentId = (int) ($department['id'] ?? 0);
if ($departmentId <= 0) {
continue;
}
$departmentTenantId = (int) ($department['tenant_id'] ?? 0);
$departmentsById[$departmentId] = [
'id' => $departmentId,
'uuid' => (string) ($department['uuid'] ?? ''),
'description' => (string) ($department['description'] ?? ''),
'active' => (bool) ($department['active'] ?? 0),
'tenant_id' => $departmentTenantId,
'tenant_uuid' => (string) (($tenantsById[$departmentTenantId]['uuid'] ?? '')),
];
}
$rolesById = [];
foreach ($this->directoryGateway->listRolesByIds($roleIds) as $role) {
$roleId = (int) ($role['id'] ?? 0);
if ($roleId <= 0) {
continue;
}
$rolesById[$roleId] = [
'id' => $roleId,
'uuid' => (string) ($role['uuid'] ?? ''),
'description' => (string) ($role['description'] ?? ''),
'active' => (bool) ($role['active'] ?? 0),
];
}
$tenants = [];
foreach ($tenantIds as $tenantId) {
if (isset($tenantsById[$tenantId])) {
$tenants[] = $tenantsById[$tenantId];
}
}
$departments = [];
foreach ($departmentIds as $departmentId) {
if (isset($departmentsById[$departmentId])) {
$departments[] = $departmentsById[$departmentId];
}
}
$roles = [];
foreach ($roleIds as $roleId) {
if (isset($rolesById[$roleId])) {
$roles[] = $rolesById[$roleId];
}
}
return [
'tenants' => $tenants,
'departments' => $departments,
'roles' => $roles,
];
}
public function findTenantSummaryInAssignments(array $assignments, int $tenantId): ?array
{
if ($tenantId <= 0) {
return null;
}
foreach (($assignments['tenants'] ?? []) as $tenant) {
$currentTenantId = (int) ($tenant['id'] ?? 0);
if ($currentTenantId !== $tenantId) {
continue;
}
return [
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
'status' => (string) ($tenant['status'] ?? ''),
];
}
return null;
}
public function mapAssignmentsToPublic(array $assignments): array
{
return [
'tenants' => array_values(array_map(
static fn (array $tenant): array => [
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
'status' => (string) ($tenant['status'] ?? ''),
],
$assignments['tenants'] ?? []
)),
'departments' => array_values(array_map(
static fn (array $department): array => [
'uuid' => (string) ($department['uuid'] ?? ''),
'description' => (string) ($department['description'] ?? ''),
'active' => (bool) ($department['active'] ?? 0),
'tenant_uuid' => (string) ($department['tenant_uuid'] ?? ''),
],
$assignments['departments'] ?? []
)),
'roles' => array_values(array_map(
static fn (array $role): array => [
'uuid' => (string) ($role['uuid'] ?? ''),
'description' => (string) ($role['description'] ?? ''),
'active' => (bool) ($role['active'] ?? 0),
],
$assignments['roles'] ?? []
)),
];
}
public function syncTenants(int $userId, array $tenantIds, bool $bumpAuthz = true): bool
{
$ids = $this->normalizeTenantIds($tenantIds);
$result = $this->userTenantRepository->replaceForUser($userId, $ids);
if ($result && $bumpAuthz) {
$this->bumpAuthzVersion($userId);
}
return $result;
}
public function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true): bool
{
$ids = array_values(array_unique(array_map('intval', $roleIds)));
$ids = array_values(array_filter($ids, static fn ($id) => $id > 0));
$validIds = $this->directoryGateway->listActiveRoleIds();
if ($validIds) {
$validMap = array_fill_keys($validIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
}
$result = $this->userRoleRepository->replaceForUser($userId, $ids);
$this->permissionGateway->clearUserCache($userId);
if ($result && $bumpAuthz) {
$this->bumpAuthzVersion($userId);
}
return $result;
}
public function syncDepartments(int $userId, array $departmentIds, bool $bumpAuthz = true): bool
{
$ids = array_values(array_unique(array_map('intval', $departmentIds)));
$ids = array_values(array_filter($ids, static fn ($id) => $id > 0));
// Important: use the user's assigned tenants directly (no permission bypass),
// otherwise privileged users (e.g. admins) would incorrectly allow departments
// from tenants that were just removed from their assignments.
$userTenantIds = array_values(array_unique(array_map(
'intval',
$this->userTenantRepository->listTenantIdsByUserId($userId)
)));
$userTenantIds = array_values(array_filter($userTenantIds, static fn ($id) => $id > 0));
if (!$userTenantIds) {
$ids = [];
} else {
$allowedIds = $this->directoryGateway->listActiveDepartmentIdsByTenantIds($userTenantIds);
if ($allowedIds) {
$allowedMap = array_fill_keys($allowedIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id])));
} else {
$ids = [];
}
}
$result = $this->userDepartmentRepository->replaceForUser($userId, $ids);
if ($result && $bumpAuthz) {
$this->bumpAuthzVersion($userId);
}
return $result;
}
public function bumpAuthzVersion(int $userId): void
{
if ($userId <= 0) {
return;
}
$this->userWriteRepository->bumpAuthzVersion($userId);
}
public function normalizeIdInput($value): array
{
$raw = is_array($value) ? $value : [$value];
$flat = [];
$collect = static function ($item) use (&$collect, &$flat): void {
if (is_array($item)) {
foreach ($item as $nested) {
$collect($nested);
}
return;
}
$text = trim((string) $item);
if ($text === '') {
return;
}
if (str_contains($text, ',')) {
foreach (explode(',', $text) as $part) {
$part = trim($part);
if ($part !== '') {
$flat[] = $part;
}
}
return;
}
$flat[] = $text;
};
foreach ($raw as $item) {
$collect($item);
}
$ids = array_values(array_unique(array_map('intval', $flat)));
return array_values(array_filter($ids, static fn ($id) => $id > 0));
}
public function normalizeTenantIds(array $tenantIds): array
{
$ids = array_values(array_unique(array_map('intval', $tenantIds)));
$ids = array_filter($ids, static fn ($id) => $id > 0);
$validIds = $this->directoryGateway->listTenantIds();
if ($validIds) {
$validMap = array_fill_keys($validIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
}
return $ids;
}
}

View File

@@ -12,38 +12,38 @@ class UserAvatarService
private const SIZES = [64, 128, 256];
private const DEFAULT_SIZE = 128;
public static function isValidUuid(string $uuid): bool
public function isValidUuid(string $uuid): bool
{
return self::imageIsValidUuid($uuid);
}
public static function storageBase(): string
public function storageBase(): string
{
return self::imageStorageBase();
}
public static function userDir(string $uuid): string
public function userDir(string $uuid): string
{
return self::storageBase() . '/users/' . $uuid;
return $this->storageBase() . '/users/' . $uuid;
}
public static function findAvatarPath(string $uuid, ?int $size = null): ?string
public function findAvatarPath(string $uuid, ?int $size = null): ?string
{
if (!self::isValidUuid($uuid)) {
if (!$this->isValidUuid($uuid)) {
return null;
}
$dir = self::userDir($uuid);
$dir = $this->userDir($uuid);
if (!is_dir($dir)) {
return null;
}
if ($size) {
$size = self::normalizeSize($size);
$variant = self::findVariantPath($dir, $size);
$size = $this->normalizeSize($size);
$variant = $this->findVariantPath($dir, $size);
if ($variant) {
return $variant;
}
}
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
$defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE);
if ($defaultVariant) {
return $defaultVariant;
}
@@ -51,18 +51,18 @@ class UserAvatarService
return $original ?: null;
}
public static function hasAvatar(string $uuid): bool
public function hasAvatar(string $uuid): bool
{
$path = self::findAvatarPath($uuid);
$path = $this->findAvatarPath($uuid);
return $path ? is_file($path) : false;
}
public static function delete(string $uuid): bool
public function delete(string $uuid): bool
{
if (!self::isValidUuid($uuid)) {
if (!$this->isValidUuid($uuid)) {
return false;
}
$dir = self::userDir($uuid);
$dir = $this->userDir($uuid);
if (!is_dir($dir)) {
return true;
}
@@ -79,9 +79,9 @@ class UserAvatarService
return true;
}
public static function saveUpload(string $uuid, array $file): array
public function saveUpload(string $uuid, array $file): array
{
if (!self::isValidUuid($uuid)) {
if (!$this->isValidUuid($uuid)) {
return ['ok' => false, 'error' => t('User not found')];
}
if (empty($file) || !isset($file['tmp_name'])) {
@@ -95,7 +95,7 @@ class UserAvatarService
}
$tmpPath = $file['tmp_name'];
$mime = self::detectMime($tmpPath);
$mime = $this->detectMime($tmpPath);
$isSvg = self::imageIsSvgUpload($mime, $tmpPath);
$ext = self::imageExtensionForMime($mime, $isSvg);
if (!$ext) {
@@ -105,12 +105,12 @@ class UserAvatarService
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = self::userDir($uuid);
$dir = $this->userDir($uuid);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete($uuid);
$this->delete($uuid);
$originalPath = $dir . '/original.' . $ext;
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
@@ -128,9 +128,9 @@ class UserAvatarService
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public static function saveBinary(string $uuid, string $contents, string $mime = ''): array
public function saveBinary(string $uuid, string $contents, string $mime = ''): array
{
if (!self::isValidUuid($uuid)) {
if (!$this->isValidUuid($uuid)) {
return ['ok' => false, 'error' => t('User not found')];
}
@@ -151,7 +151,7 @@ class UserAvatarService
return ['ok' => false, 'error' => t('Upload failed')];
}
$detectedMime = self::detectMime($tmpPath);
$detectedMime = $this->detectMime($tmpPath);
if ($detectedMime === 'application/octet-stream') {
$headerMime = strtolower(trim(explode(';', $mime, 2)[0]));
if (in_array($headerMime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
@@ -164,13 +164,13 @@ class UserAvatarService
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = self::userDir($uuid);
$dir = $this->userDir($uuid);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
@unlink($tmpPath);
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete($uuid);
$this->delete($uuid);
$originalPath = $dir . '/original.' . $ext;
if (!@rename($tmpPath, $originalPath)) {
if (!@copy($tmpPath, $originalPath)) {
@@ -192,12 +192,12 @@ class UserAvatarService
return ['ok' => true, 'path' => $originalPath, 'mime' => $detectedMime];
}
public static function detectMime(string $path): string
public function detectMime(string $path): string
{
return self::imageDetectMime($path);
}
private static function normalizeSize(int $size): int
private function normalizeSize(int $size): int
{
if (in_array($size, self::SIZES, true)) {
return $size;
@@ -205,7 +205,7 @@ class UserAvatarService
return self::DEFAULT_SIZE;
}
private static function findVariantPath(string $dir, int $size): ?string
private function findVariantPath(string $dir, int $size): ?string
{
$matches = glob($dir . '/avatar-' . $size . '.*');
if (!$matches) {

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
class UserDirectoryGateway
{
public function listTenantIds(): array
{
return (new TenantRepository())->listIds();
}
public function listTenantsByIds(array $tenantIds): array
{
return (new TenantRepository())->listByIds($tenantIds);
}
public function findTenant(int $tenantId): ?array
{
return (new TenantRepository())->find($tenantId);
}
public function listActiveTenantIdsByIds(array $tenantIds): array
{
return (new TenantRepository())->listActiveIdsByIds($tenantIds);
}
public function listActiveRoleIds(): array
{
return (new RoleRepository())->listActiveIds();
}
public function listRolesByIds(array $roleIds): array
{
return (new RoleRepository())->listByIds($roleIds);
}
public function listDepartmentsByIds(array $departmentIds, bool $includeInactive = true): array
{
return (new DepartmentRepository())->listByIds($departmentIds, $includeInactive);
}
public function listActiveDepartmentIdsByTenantIds(array $tenantIds): array
{
return (new DepartmentRepository())->listActiveIdsByTenantIds($tenantIds);
}
public function listDepartmentsByTenantIds(array $tenantIds): array
{
return (new DepartmentRepository())->listByTenantIds($tenantIds);
}
}

View File

@@ -4,12 +4,20 @@ namespace MintyPHP\Service\User;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\User\UserRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserWriteRepository;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
class UserLifecycleRestoreService
{
public static function restoreFromLifecycleAudit(int $auditId, int $actorUserId): array
public function __construct(
private readonly UserReadRepository $userReadRepository,
private readonly UserWriteRepository $userWriteRepository,
private readonly UserLifecycleAuditService $userLifecycleAuditService
) {
}
public function restoreFromLifecycleAudit(int $auditId, int $actorUserId): array
{
if ($auditId <= 0 || $actorUserId <= 0) {
return ['ok' => false, 'error' => 'invalid_request'];
@@ -18,7 +26,7 @@ class UserLifecycleRestoreService
$db = DB::handle();
$db->begin_transaction();
try {
$event = UserLifecycleAuditService::findDeleteEventForRestore($auditId, true);
$event = $this->userLifecycleAuditService->findDeleteEventForRestore($auditId, true);
if (!$event) {
$db->rollback();
return ['ok' => false, 'error' => 'audit_event_not_found'];
@@ -28,7 +36,7 @@ class UserLifecycleRestoreService
return ['ok' => false, 'error' => 'audit_event_already_restored'];
}
$snapshot = UserLifecycleAuditService::decryptSnapshot($event);
$snapshot = $this->userLifecycleAuditService->decryptSnapshot($event);
if (!$snapshot) {
$db->rollback();
return ['ok' => false, 'error' => 'snapshot_unavailable'];
@@ -41,35 +49,35 @@ class UserLifecycleRestoreService
return ['ok' => false, 'error' => 'snapshot_invalid'];
}
if (UserRepository::findByUuid($uuid)) {
if ($this->userReadRepository->findByUuid($uuid)) {
$db->rollback();
return ['ok' => false, 'error' => 'restore_uuid_exists'];
}
if (UserRepository::findByEmail($email)) {
if ($this->userReadRepository->findByEmail($email)) {
$db->rollback();
return ['ok' => false, 'error' => 'restore_email_exists'];
}
$createdId = UserRepository::create([
$createdId = $this->userWriteRepository->create([
'uuid' => $uuid,
'first_name' => trim((string) ($snapshot['first_name'] ?? '')),
'last_name' => trim((string) ($snapshot['last_name'] ?? '')),
'email' => $email,
'profile_description' => self::nullableText($snapshot['profile_description'] ?? null),
'job_title' => self::nullableText($snapshot['job_title'] ?? null),
'phone' => self::nullableText($snapshot['phone'] ?? null),
'mobile' => self::nullableText($snapshot['mobile'] ?? null),
'short_dial' => self::nullableText($snapshot['short_dial'] ?? null),
'address' => self::nullableText($snapshot['address'] ?? null),
'postal_code' => self::nullableText($snapshot['postal_code'] ?? null),
'city' => self::nullableText($snapshot['city'] ?? null),
'country' => self::nullableText($snapshot['country'] ?? null),
'region' => self::nullableText($snapshot['region'] ?? null),
'hire_date' => self::nullableDate($snapshot['hire_date'] ?? null),
'password' => self::randomPassword(),
'locale' => self::nullableText($snapshot['locale'] ?? null),
'profile_description' => $this->nullableText($snapshot['profile_description'] ?? null),
'job_title' => $this->nullableText($snapshot['job_title'] ?? null),
'phone' => $this->nullableText($snapshot['phone'] ?? null),
'mobile' => $this->nullableText($snapshot['mobile'] ?? null),
'short_dial' => $this->nullableText($snapshot['short_dial'] ?? null),
'address' => $this->nullableText($snapshot['address'] ?? null),
'postal_code' => $this->nullableText($snapshot['postal_code'] ?? null),
'city' => $this->nullableText($snapshot['city'] ?? null),
'country' => $this->nullableText($snapshot['country'] ?? null),
'region' => $this->nullableText($snapshot['region'] ?? null),
'hire_date' => $this->nullableDate($snapshot['hire_date'] ?? null),
'password' => $this->randomPassword(),
'locale' => $this->nullableText($snapshot['locale'] ?? null),
'totp_secret' => '',
'theme' => self::normalizeTheme($snapshot['theme'] ?? null),
'theme' => $this->normalizeTheme($snapshot['theme'] ?? null),
'primary_tenant_id' => null,
'current_tenant_id' => null,
'created_by' => $actorUserId,
@@ -83,13 +91,17 @@ class UserLifecycleRestoreService
}
$restoredUserId = (int) $createdId;
$marked = UserLifecycleAuditService::markDeleteEventRestored($auditId, $actorUserId, $restoredUserId);
$marked = $this->userLifecycleAuditService->markDeleteEventRestored(
$auditId,
$actorUserId,
$restoredUserId
);
if (!$marked) {
$db->rollback();
return ['ok' => false, 'error' => 'restore_mark_failed'];
}
UserLifecycleAuditService::logRestore(
$this->userLifecycleAuditService->logRestore(
RepoQuery::uuidV4(),
'manual',
[
@@ -123,13 +135,13 @@ class UserLifecycleRestoreService
}
}
private static function nullableText(mixed $value): ?string
private function nullableText(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private static function nullableDate(mixed $value): ?string
private function nullableDate(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
@@ -141,16 +153,15 @@ class UserLifecycleRestoreService
return $value;
}
private static function randomPassword(): string
private function randomPassword(): string
{
return bin2hex(random_bytes(24));
}
private static function normalizeTheme(mixed $value): string
private function normalizeTheme(mixed $value): string
{
$theme = strtolower(trim((string) $value));
$themes = appThemes();
return isset($themes[$theme]) ? $theme : appDefaultTheme();
}
}

View File

@@ -3,29 +3,37 @@
namespace MintyPHP\Service\User;
use MintyPHP\DB;
use MintyPHP\Repository\User\UserRepository;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserWriteRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\Settings\SettingService;
class UserLifecycleService
{
private const LOCK_NAME = 'user_lifecycle_maintenance';
private const BATCH_SIZE = 500;
public static function run(?int $actorUserId = null): array
public function __construct(
private readonly UserReadRepository $userReadRepository,
private readonly UserWriteRepository $userWriteRepository,
private readonly UserSettingsGateway $settingsGateway,
private readonly UserLifecycleAuditService $userLifecycleAuditService
) {
}
public function run(?int $actorUserId = null): array
{
$startedAt = microtime(true);
$runUuid = RepoQuery::uuidV4();
$triggerType = ($actorUserId ?? 0) > 0 ? 'manual' : 'cron';
$deactivateDays = SettingService::getUserInactivityDeactivateDays();
$deleteDays = SettingService::getUserInactivityDeleteDays();
$deactivateDays = $this->settingsGateway->getUserInactivityDeactivateDays();
$deleteDays = $this->settingsGateway->getUserInactivityDeleteDays();
if ($deactivateDays <= 0) {
$deleteDays = 0;
}
$privilegedUserIds = UserRepository::listPrivilegedUserIdsByPermissionKeys([
$privilegedUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys([
PermissionService::SETTINGS_UPDATE,
PermissionService::TENANTS_UPDATE,
]);
@@ -46,30 +54,30 @@ class UserLifecycleService
'duration_ms' => 0,
];
if (!self::acquireLock()) {
if (!$this->acquireLock()) {
$result['ok'] = false;
$result['locked'] = true;
$result['error'] = 'lock_not_acquired';
$result['duration_ms'] = self::durationMs($startedAt);
$result['duration_ms'] = $this->durationMs($startedAt);
return $result;
}
try {
if ($deactivateDays > 0) {
while (true) {
$ids = UserRepository::listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE);
$ids = $this->userReadRepository->listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE);
if (!$ids) {
break;
}
$usersById = [];
foreach ($ids as $id) {
$user = UserRepository::find((int) $id);
$user = $this->userReadRepository->find((int) $id);
if ($user) {
$usersById[(int) $id] = $user;
}
}
$updated = UserRepository::setInactiveByIds(
$updated = $this->userWriteRepository->setInactiveByIds(
$ids,
$actorUserId && $actorUserId > 0 ? $actorUserId : null
);
@@ -79,12 +87,12 @@ class UserLifecycleService
break;
}
$result['deactivated_count'] += $updated;
UserRepository::bumpAuthzVersionByUserIds($ids);
$this->userWriteRepository->bumpAuthzVersionByUserIds($ids);
foreach ($ids as $id) {
if (!isset($usersById[(int) $id])) {
continue;
}
UserLifecycleAuditService::logDeactivate(
$this->userLifecycleAuditService->logDeactivate(
$runUuid,
$triggerType,
$result['policy'],
@@ -102,18 +110,18 @@ class UserLifecycleService
if ($deleteDays > 0) {
while (true) {
$ids = UserRepository::listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE);
$ids = $this->userReadRepository->listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE);
if (!$ids) {
break;
}
foreach ($ids as $id) {
$user = UserRepository::find((int) $id);
$user = $this->userReadRepository->find((int) $id);
if (!$user) {
continue;
}
$auditId = UserLifecycleAuditService::logDeleteWithSnapshot(
$auditId = $this->userLifecycleAuditService->logDeleteWithSnapshot(
$runUuid,
$triggerType,
$result['policy'],
@@ -122,7 +130,7 @@ class UserLifecycleService
);
if (!$auditId) {
$result['skipped_count']++;
UserLifecycleAuditService::logDeleteFailure(
$this->userLifecycleAuditService->logDeleteFailure(
$runUuid,
$triggerType,
$result['policy'],
@@ -133,10 +141,10 @@ class UserLifecycleService
continue;
}
$deleted = UserRepository::deleteByIds([(int) $id]);
$deleted = $this->userWriteRepository->deleteByIds([(int) $id]);
if ($deleted <= 0) {
$result['skipped_count']++;
UserLifecycleAuditService::updateStatus((int) $auditId, 'failed', 'delete_failed');
$this->userLifecycleAuditService->updateStatus((int) $auditId, 'failed', 'delete_failed');
continue;
}
$result['deleted_count'] += $deleted;
@@ -150,20 +158,20 @@ class UserLifecycleService
$result['ok'] = false;
$result['error'] = 'unexpected_error';
} finally {
self::releaseLock();
$result['duration_ms'] = self::durationMs($startedAt);
$this->releaseLock();
$result['duration_ms'] = $this->durationMs($startedAt);
}
return $result;
}
private static function acquireLock(): bool
private function acquireLock(): bool
{
$gotLock = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::LOCK_NAME);
return (int) $gotLock === 1;
}
private static function releaseLock(): void
private function releaseLock(): void
{
try {
DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::LOCK_NAME);
@@ -172,7 +180,7 @@ class UserLifecycleService
}
}
private static function durationMs(float $startedAt): int
private function durationMs(float $startedAt): int
{
return (int) max(0, round((microtime(true) - $startedAt) * 1000));
}

View File

@@ -0,0 +1,74 @@
<?php
namespace MintyPHP\Service\User;
class UserPasswordPolicyService
{
private const PASSWORD_MIN_LENGTH = 12;
public function validate(string $password, string $password2, bool $required, ?string $email): array
{
$errors = [];
if ($required && $password === '') {
$errors[] = $this->translate('Password cannot be empty');
return $errors;
}
if ($password !== '' && $password !== $password2) {
$errors[] = $this->translate('Passwords must match');
}
if ($password === '') {
return $errors;
}
if (strlen($password) < self::PASSWORD_MIN_LENGTH) {
$errors[] = $this->translate('Password must be at least %d characters', self::PASSWORD_MIN_LENGTH);
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = $this->translate('Password must include an uppercase letter');
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = $this->translate('Password must include a lowercase letter');
}
if (!preg_match('/\d/', $password)) {
$errors[] = $this->translate('Password must include a number');
}
if (!preg_match('/[^a-zA-Z0-9]/', $password)) {
$errors[] = $this->translate('Password must include a symbol');
}
if ($email !== null) {
$email = trim($email);
if ($email !== '' && stripos($password, $email) !== false) {
$errors[] = $this->translate('Password must not contain your email');
}
}
return $errors;
}
public function hints(): array
{
return [
['rule' => 'min', 'text' => $this->translate('At least %d characters', self::PASSWORD_MIN_LENGTH)],
['rule' => 'upper', 'text' => $this->translate('At least one uppercase letter')],
['rule' => 'lower', 'text' => $this->translate('At least one lowercase letter')],
['rule' => 'number', 'text' => $this->translate('At least one number')],
['rule' => 'symbol', 'text' => $this->translate('At least one symbol')],
['rule' => 'email', 'text' => $this->translate('Must not contain your email')],
];
}
public function minLength(): int
{
return self::PASSWORD_MIN_LENGTH;
}
private function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args));
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Repository\User\UserWriteRepository;
class UserPasswordService
{
public function __construct(
private readonly UserPasswordPolicyService $passwordPolicyService,
private readonly UserWriteRepository $userWriteRepository
) {
}
public function validatePassword(
string $password,
string $password2,
bool $required,
?string $email
): array {
return $this->passwordPolicyService->validate($password, $password2, $required, $email);
}
public function passwordHints(): array
{
return $this->passwordPolicyService->hints();
}
public function passwordMinLength(): int
{
return $this->passwordPolicyService->minLength();
}
public function resetPassword(int $userId, string $password, string $password2): array
{
$errors = $this->passwordPolicyService->validate($password, $password2, true, '');
if ($errors) {
return ['ok' => false, 'errors' => $errors];
}
$updated = $this->userWriteRepository->setPassword($userId, $password);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Password can not be updated')]];
}
return ['ok' => true];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Service\Access\PermissionGateway;
class UserPermissionGateway
{
public function __construct(private readonly PermissionGateway $permissionGateway)
{
}
public function clearUserCache(int $userId): void
{
$this->permissionGateway->clearUserCache($userId);
}
}

View File

@@ -12,27 +12,31 @@ class UserSavedFilterService
private const SEARCH_MAX_LENGTH = 200;
private const MAX_PER_USER_CONTEXT = 20;
public static function listForAddressBook(int $userId): array
public function __construct(private readonly UserSavedFilterRepository $userSavedFilterRepository)
{
return self::listByContext($userId, self::CONTEXT_ADDRESS_BOOK);
}
public static function saveAddressBookFilter(int $userId, string $name, array $rawQuery): array
public function listForAddressBook(int $userId): array
{
return self::saveByContext($userId, self::CONTEXT_ADDRESS_BOOK, $name, $rawQuery);
return $this->listByContext($userId, self::CONTEXT_ADDRESS_BOOK);
}
public static function deleteAddressBookFilter(int $userId, string $uuid): bool
public function saveAddressBookFilter(int $userId, string $name, array $rawQuery): array
{
return self::deleteByContext($userId, self::CONTEXT_ADDRESS_BOOK, $uuid);
return $this->saveByContext($userId, self::CONTEXT_ADDRESS_BOOK, $name, $rawQuery);
}
public static function listByContext(int $userId, string $context): array
public function deleteAddressBookFilter(int $userId, string $uuid): bool
{
return $this->deleteByContext($userId, self::CONTEXT_ADDRESS_BOOK, $uuid);
}
public function listByContext(int $userId, string $context): array
{
if ($userId <= 0 || $context === '') {
return [];
}
$rows = UserSavedFilterRepository::listByUserAndContext($userId, $context);
$rows = $this->userSavedFilterRepository->listByUserAndContext($userId, $context);
$list = [];
foreach ($rows as $row) {
$name = trim((string) ($row['name'] ?? ''));
@@ -40,7 +44,7 @@ class UserSavedFilterService
if ($name === '' || $uuid === '') {
continue;
}
$query = self::normalizeQuery(self::decodeQuery((string) ($row['query_json'] ?? '')));
$query = $this->normalizeQuery($this->decodeQuery((string) ($row['query_json'] ?? '')));
$list[] = [
'id' => (int) ($row['id'] ?? 0),
'uuid' => $uuid,
@@ -52,25 +56,25 @@ class UserSavedFilterService
return $list;
}
public static function saveByContext(int $userId, string $context, string $name, array $rawQuery): array
public function saveByContext(int $userId, string $context, string $name, array $rawQuery): array
{
$name = self::truncate(trim($name), self::NAME_MAX_LENGTH);
$name = $this->truncate(trim($name), self::NAME_MAX_LENGTH);
if ($userId <= 0 || $context === '') {
return ['ok' => false, 'error' => 'invalid_user_or_context'];
}
if ($name === '') {
return ['ok' => false, 'error' => 'name_required'];
}
$count = UserSavedFilterRepository::countByUserAndContext($userId, $context);
$count = $this->userSavedFilterRepository->countByUserAndContext($userId, $context);
if ($count >= self::MAX_PER_USER_CONTEXT) {
return ['ok' => false, 'error' => 'max_reached'];
}
$query = self::normalizeQuery($rawQuery);
$query = $this->normalizeQuery($rawQuery);
$queryJson = json_encode($query, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($queryJson)) {
$queryJson = '{}';
}
$id = UserSavedFilterRepository::create([
$id = $this->userSavedFilterRepository->create([
'user_id' => $userId,
'context' => $context,
'name' => $name,
@@ -82,27 +86,27 @@ class UserSavedFilterService
return ['ok' => true, 'id' => (int) $id, 'name' => $name, 'query' => $query];
}
public static function deleteByContext(int $userId, string $context, string $uuid): bool
public function deleteByContext(int $userId, string $context, string $uuid): bool
{
if ($userId <= 0 || $context === '' || trim($uuid) === '') {
return false;
}
return UserSavedFilterRepository::deleteByUuidForUserAndContext($uuid, $userId, $context);
return $this->userSavedFilterRepository->deleteByUuidForUserAndContext($uuid, $userId, $context);
}
private static function decodeQuery(string $json): array
private function decodeQuery(string $json): array
{
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private static function normalizeQuery(array $rawQuery): array
private function normalizeQuery(array $rawQuery): array
{
$search = self::truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH);
$tenants = self::normalizeUuidList($rawQuery['tenants'] ?? []);
$search = $this->truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH);
$tenants = $this->normalizeUuidList($rawQuery['tenants'] ?? []);
$departments = RepoQuery::normalizeIdList($rawQuery['departments'] ?? []);
$roles = RepoQuery::normalizeIdList($rawQuery['roles'] ?? []);
$custom = self::normalizeCustomFieldQuery($rawQuery);
$custom = $this->normalizeCustomFieldQuery($rawQuery);
$query = [];
if ($search !== '') {
@@ -124,7 +128,7 @@ class UserSavedFilterService
return $query;
}
private static function normalizeCustomFieldQuery(array $rawQuery): array
private function normalizeCustomFieldQuery(array $rawQuery): array
{
$normalized = [];
foreach ($rawQuery as $rawKey => $rawValue) {
@@ -158,9 +162,9 @@ class UserSavedFilterService
return $normalized;
}
private static function normalizeUuidList($value): array
private function normalizeUuidList($value): array
{
$items = self::normalizeStringList($value);
$items = $this->normalizeStringList($value);
$list = [];
foreach ($items as $item) {
$item = strtolower($item);
@@ -173,7 +177,7 @@ class UserSavedFilterService
return $list;
}
private static function normalizeStringList($value): array
private function normalizeStringList($value): array
{
$raw = is_array($value) ? $value : [$value];
$flat = [];
@@ -205,7 +209,7 @@ class UserSavedFilterService
return array_values(array_unique($flat));
}
private static function truncate(string $value, int $maxLength): string
private function truncate(string $value, int $maxLength): string
{
if ($maxLength <= 0 || $value === '') {
return $value;

View File

@@ -0,0 +1,37 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantServicesFactory;
class UserScopeGateway
{
public function __construct(private readonly ?TenantScopeService $tenantScopeService = null)
{
}
public function hasGlobalAccess(int $userId): bool
{
return $this->tenantScopeService()->hasGlobalAccess($userId);
}
public function canAccess(string $resourceType, int $resourceId, int $userId): bool
{
return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId);
}
public function getUserTenantIds(int $userId): array
{
return $this->tenantScopeService()->getUserTenantIds($userId);
}
private function tenantScopeService(): TenantScopeService
{
if ($this->tenantScopeService instanceof TenantScopeService) {
return $this->tenantScopeService;
}
return (new TenantServicesFactory())->createTenantScopeService();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Repository\Org\UserDepartmentRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserListQueryRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserSavedFilterRepository;
use MintyPHP\Repository\User\UserWriteRepository;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Tenant\TenantServicesFactory;
class UserServicesFactory
{
private ?AccessServicesFactory $accessServicesFactory = null;
private ?TenantServicesFactory $tenantServicesFactory = null;
private ?UserReadRepository $userReadRepository = null;
private ?AuditServicesFactory $auditServicesFactory = null;
private ?SettingServicesFactory $settingServicesFactory = null;
private ?UserWriteRepository $userWriteRepository = null;
private ?UserListQueryRepository $userListQueryRepository = null;
private ?UserSavedFilterRepository $userSavedFilterRepository = null;
private ?UserTenantRepository $userTenantRepository = null;
private ?UserRoleRepository $userRoleRepository = null;
private ?UserDepartmentRepository $userDepartmentRepository = null;
private ?UserPasswordPolicyService $userPasswordPolicyService = null;
private ?UserPasswordService $userPasswordService = null;
private ?UserAvatarService $userAvatarService = null;
private ?UserSavedFilterService $userSavedFilterService = null;
private ?UserSettingsGateway $userSettingsGateway = null;
private ?SettingGateway $settingGateway = null;
private ?UserScopeGateway $userScopeGateway = null;
private ?UserDirectoryGateway $userDirectoryGateway = null;
private ?UserPermissionGateway $userPermissionGateway = null;
private ?PermissionGateway $permissionGateway = null;
private ?UserAssignmentService $userAssignmentService = null;
private ?UserTenantContextService $userTenantContextService = null;
private ?UserAccountService $userAccountService = null;
private ?UserLifecycleService $userLifecycleService = null;
private ?UserLifecycleRestoreService $userLifecycleRestoreService = null;
private ?UserLifecycleAuditService $userLifecycleAuditService = null;
public function createUserAccountService(): UserAccountService
{
return $this->userAccountService ??= new UserAccountService(
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserListQueryRepository(),
$this->createUserAssignmentService(),
$this->createUserPasswordService(),
$this->createUserSettingsGateway(),
$this->createUserScopeGateway()
);
}
public function createUserAssignmentService(): UserAssignmentService
{
return $this->userAssignmentService ??= new UserAssignmentService(
$this->createUserWriteRepository(),
$this->createUserTenantRepository(),
$this->createUserRoleRepository(),
$this->createUserDepartmentRepository(),
$this->createUserDirectoryGateway(),
$this->createUserPermissionGateway()
);
}
public function createUserTenantContextService(): UserTenantContextService
{
return $this->userTenantContextService ??= new UserTenantContextService(
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserTenantRepository(),
$this->createUserScopeGateway(),
$this->createUserDirectoryGateway()
);
}
public function createUserPasswordService(): UserPasswordService
{
return $this->userPasswordService ??= new UserPasswordService(
$this->createUserPasswordPolicyService(),
$this->createUserWriteRepository()
);
}
public function createUserAvatarService(): UserAvatarService
{
return $this->userAvatarService ??= new UserAvatarService();
}
public function createUserSavedFilterService(): UserSavedFilterService
{
return $this->userSavedFilterService ??= new UserSavedFilterService(
$this->createUserSavedFilterRepository()
);
}
public function createUserReadRepository(): UserReadRepository
{
return $this->userReadRepository ??= new UserReadRepository();
}
public function createUserWriteRepository(): UserWriteRepository
{
return $this->userWriteRepository ??= new UserWriteRepository();
}
public function createUserListQueryRepository(): UserListQueryRepository
{
return $this->userListQueryRepository ??= new UserListQueryRepository();
}
public function createUserSavedFilterRepository(): UserSavedFilterRepository
{
return $this->userSavedFilterRepository ??= new UserSavedFilterRepository();
}
public function createUserTenantRepository(): UserTenantRepository
{
return $this->userTenantRepository ??= new UserTenantRepository();
}
public function createUserRoleRepository(): UserRoleRepository
{
return $this->userRoleRepository ??= new UserRoleRepository();
}
public function createUserDepartmentRepository(): UserDepartmentRepository
{
return $this->userDepartmentRepository ??= new UserDepartmentRepository();
}
public function createUserPasswordPolicyService(): UserPasswordPolicyService
{
return $this->userPasswordPolicyService ??= new UserPasswordPolicyService();
}
public function createUserSettingsGateway(): UserSettingsGateway
{
return $this->userSettingsGateway ??= new UserSettingsGateway($this->createSettingGateway());
}
public function createUserScopeGateway(): UserScopeGateway
{
return $this->userScopeGateway ??= new UserScopeGateway($this->tenantServicesFactory()->createTenantScopeService());
}
public function createUserDirectoryGateway(): UserDirectoryGateway
{
return $this->userDirectoryGateway ??= new UserDirectoryGateway();
}
public function createUserPermissionGateway(): UserPermissionGateway
{
return $this->userPermissionGateway ??= new UserPermissionGateway($this->createPermissionGateway());
}
private function createPermissionGateway(): PermissionGateway
{
return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway();
}
private function accessServicesFactory(): AccessServicesFactory
{
return $this->accessServicesFactory ??= new AccessServicesFactory();
}
private function createSettingGateway(): SettingGateway
{
return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway();
}
private function settingServicesFactory(): SettingServicesFactory
{
return $this->settingServicesFactory ??= new SettingServicesFactory();
}
private function tenantServicesFactory(): TenantServicesFactory
{
return $this->tenantServicesFactory ??= new TenantServicesFactory();
}
public function createUserLifecycleService(): UserLifecycleService
{
return $this->userLifecycleService ??= new UserLifecycleService(
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserSettingsGateway(),
$this->createUserLifecycleAuditService()
);
}
public function createUserLifecycleRestoreService(): UserLifecycleRestoreService
{
return $this->userLifecycleRestoreService ??= new UserLifecycleRestoreService(
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserLifecycleAuditService()
);
}
private function createUserLifecycleAuditService(): UserLifecycleAuditService
{
return $this->userLifecycleAuditService ??= $this->auditServicesFactory()->createUserLifecycleAuditService();
}
private function auditServicesFactory(): AuditServicesFactory
{
return $this->auditServicesFactory ??= new AuditServicesFactory();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Service\Settings\SettingGateway;
class UserSettingsGateway
{
public function __construct(private readonly SettingGateway $settingGateway)
{
}
public function getDefaultTenantId(): ?int
{
return $this->settingGateway->getDefaultTenantId();
}
public function getDefaultRoleId(): ?int
{
return $this->settingGateway->getDefaultRoleId();
}
public function getDefaultDepartmentId(): ?int
{
return $this->settingGateway->getDefaultDepartmentId();
}
public function getAppTheme(): ?string
{
return $this->settingGateway->getAppTheme();
}
public function getUserInactivityDeactivateDays(): int
{
return $this->settingGateway->getUserInactivityDeactivateDays();
}
public function getUserInactivityDeleteDays(): int
{
return $this->settingGateway->getUserInactivityDeleteDays();
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserWriteRepository;
class UserTenantContextService
{
public function __construct(
private readonly UserReadRepository $userReadRepository,
private readonly UserWriteRepository $userWriteRepository,
private readonly UserTenantRepository $userTenantRepository,
private readonly UserScopeGateway $scopeGateway,
private readonly UserDirectoryGateway $directoryGateway
) {
}
/**
* Get the current tenant ID for a user (with fallback to primary tenant)
*/
public function getCurrentTenantId(int $userId): ?int
{
$user = $this->userReadRepository->find($userId);
if (!$user) {
return null;
}
$activeTenantIds = $this->scopeGateway->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 function getCurrentTenant(int $userId): ?array
{
$currentTenantId = $this->getCurrentTenantId($userId);
if (!$currentTenantId) {
return null;
}
return $this->directoryGateway->findTenant($currentTenantId);
}
/**
* Get all tenants the user has access to
*/
public function getAvailableTenants(int $userId): array
{
$tenantIds = $this->scopeGateway->getUserTenantIds($userId);
if (!$tenantIds) {
return [];
}
$tenants = [];
foreach ($tenantIds as $tenantId) {
$tenant = $this->directoryGateway->findTenant((int) $tenantId);
if ($tenant && (($tenant['status'] ?? 'active') === 'active')) {
$tenants[] = $tenant;
}
}
usort($tenants, static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});
return $tenants;
}
/**
* Get only explicitly assigned active tenants for a user.
* This intentionally ignores global tenant-scope bypass permissions.
*/
public function getAssignedActiveTenants(int $userId): array
{
if ($userId <= 0) {
return [];
}
$tenantIds = array_values(array_unique(array_map(
'intval',
$this->userTenantRepository->listTenantIdsByUserId($userId)
)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$activeTenantIds = $this->directoryGateway->listActiveTenantIdsByIds($tenantIds);
if (!$activeTenantIds) {
return [];
}
$tenantsById = [];
foreach ($this->directoryGateway->listTenantsByIds($activeTenantIds) as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantsById[$tenantId] = $tenant;
}
$tenants = [];
foreach ($tenantIds as $tenantId) {
if (isset($tenantsById[$tenantId])) {
$tenants[] = $tenantsById[$tenantId];
}
}
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 function getAvailableDepartmentsByTenant(int $userId): array
{
$tenants = $this->getAvailableTenants($userId);
if (!$tenants) {
return [];
}
$groups = [];
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$departments = $this->directoryGateway->listDepartmentsByTenantIds([$tenantId]);
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 function setCurrentTenant(int $userId, int $tenantId): array
{
if ($tenantId <= 0) {
return ['ok' => false, 'error' => 'invalid_tenant'];
}
$userTenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId);
if (!in_array($tenantId, $userTenantIds, true)) {
return ['ok' => false, 'error' => 'tenant_not_assigned'];
}
$tenant = $this->directoryGateway->findTenant($tenantId);
if (!$tenant) {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
if (($tenant['status'] ?? 'active') !== 'active') {
return ['ok' => false, 'error' => 'tenant_inactive'];
}
$updated = $this->userWriteRepository->setCurrentTenant($userId, $tenantId);
if (!$updated) {
return ['ok' => false, 'error' => 'update_failed'];
}
return ['ok' => true, 'tenant' => $tenant];
}
}