2026-02-23 12:58:19 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Service\User;
|
|
|
|
|
|
2026-03-19 18:20:13 +01:00
|
|
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
2026-04-14 08:23:04 +02:00
|
|
|
use MintyPHP\App\Module\ModuleEvents;
|
2026-02-23 12:58:19 +01:00
|
|
|
use MintyPHP\I18n;
|
2026-03-09 19:16:26 +01:00
|
|
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
2026-03-05 08:26:51 +01:00
|
|
|
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
2026-03-13 11:31:33 +01:00
|
|
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-13 21:58:51 +01:00
|
|
|
/**
|
|
|
|
|
* User account lifecycle: creation, updates, activation, deletion, and self-service profile edits.
|
|
|
|
|
*
|
|
|
|
|
* Write operations (createFromAdmin, register) run inside a DB transaction to ensure
|
|
|
|
|
* atomicity of user record + tenant/role/department assignments. On any failure the
|
|
|
|
|
* transaction is rolled back and no partial state is left behind.
|
|
|
|
|
*
|
|
|
|
|
* Tenant-scoped operations (delete, bulk activate/deactivate) filter UUIDs through
|
|
|
|
|
* TenantScopeService before executing, so a scoped admin can only affect users
|
|
|
|
|
* within their own tenant boundary. Self-delete and self-deactivate are always blocked.
|
|
|
|
|
*
|
|
|
|
|
* Every state change is recorded via SystemAuditService with before/after snapshots
|
|
|
|
|
* where applicable (e.g. active flag, locale, theme, primary tenant).
|
|
|
|
|
*/
|
2026-02-23 12:58:19 +01:00
|
|
|
class UserAccountService
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
2026-03-05 08:26:51 +01:00
|
|
|
private readonly UserReadRepositoryInterface $userReadRepository,
|
|
|
|
|
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
|
|
|
|
private readonly UserListQueryRepositoryInterface $userListQueryRepository,
|
2026-02-23 12:58:19 +01:00
|
|
|
private readonly UserAssignmentService $userAssignmentService,
|
|
|
|
|
private readonly UserPasswordService $userPasswordService,
|
|
|
|
|
private readonly UserSettingsGateway $settingsGateway,
|
2026-03-13 11:31:33 +01:00
|
|
|
private readonly TenantScopeService $scopeGateway,
|
2026-03-04 15:56:58 +01:00
|
|
|
private readonly UserDirectoryGateway $directoryGateway,
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
private readonly AuditRecorderInterface $systemAuditService,
|
2026-03-19 18:20:13 +01:00
|
|
|
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
|
|
|
|
private readonly ?ModuleEventDispatcher $eventDispatcher = null
|
2026-02-23 12:58:19 +01:00
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function listPaged(array $options): array
|
|
|
|
|
{
|
2026-03-06 00:44:52 +01:00
|
|
|
// Users with global tenant scope see all tenants — remove the filter so the query isn't restricted.
|
2026-02-23 12:58:19 +01:00
|
|
|
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'),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 22:09:44 +01:00
|
|
|
// Capture tenant associations before CASCADE deletes them
|
2026-03-20 00:05:03 +01:00
|
|
|
$preDeletionAssignments = $this->safeAssignmentsForUser($userId);
|
|
|
|
|
$preDeletionTenantIds = $this->extractTenantIdsFromAssignments($preDeletionAssignments);
|
2026-03-19 22:09:44 +01:00
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$deleted = $this->userWriteRepository->delete($userId);
|
|
|
|
|
if (!$deleted) {
|
|
|
|
|
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
$this->systemAuditService->record('admin.users.delete', 'success', [
|
|
|
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
|
|
|
'target_type' => 'user',
|
|
|
|
|
'target_id' => $userId,
|
|
|
|
|
'target_uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-14 08:23:04 +02:00
|
|
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_DELETED, [
|
2026-03-19 18:20:13 +01:00
|
|
|
'user_id' => $userId,
|
|
|
|
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
'actor_user_id' => $currentUserId,
|
2026-03-19 22:09:44 +01:00
|
|
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
|
|
|
|
'tenant_ids' => $preDeletionTenantIds,
|
2026-03-19 18:20:13 +01:00
|
|
|
]);
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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'];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
$deletedUsers = [];
|
|
|
|
|
foreach ($uuids as $deleteUuid) {
|
|
|
|
|
$user = $this->userReadRepository->findByUuid((string) $deleteUuid);
|
|
|
|
|
if (!$user || !isset($user['id'])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$deleteUserId = (int) $user['id'];
|
|
|
|
|
if ($deleteUserId <= 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$assignments = $this->safeAssignmentsForUser($deleteUserId);
|
|
|
|
|
$deletedUsers[] = [
|
|
|
|
|
'id' => $deleteUserId,
|
|
|
|
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
|
|
|
|
'tenant_ids' => $this->extractTenantIdsFromAssignments($assignments),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$deleted = $this->userWriteRepository->deleteByUuids($uuids);
|
|
|
|
|
if (!$deleted) {
|
|
|
|
|
return ['ok' => false, 'error' => 'delete_failed'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
$this->systemAuditService->record('admin.users.bulk_update', 'success', [
|
|
|
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
|
|
|
'target_type' => 'user',
|
|
|
|
|
'metadata' => [
|
|
|
|
|
'action' => 'delete',
|
|
|
|
|
'count' => count($uuids),
|
|
|
|
|
'target_uuids' => $uuids,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
foreach ($deletedUsers as $deletedUser) {
|
2026-04-14 08:23:04 +02:00
|
|
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_DELETED, [
|
2026-03-22 14:15:29 +01:00
|
|
|
'user_id' => (int) $deletedUser['id'],
|
|
|
|
|
'uuid' => (string) $deletedUser['uuid'],
|
2026-03-20 00:05:03 +01:00
|
|
|
'actor_user_id' => $currentUserId,
|
2026-03-22 14:15:29 +01:00
|
|
|
'display_name' => (string) $deletedUser['display_name'],
|
|
|
|
|
'tenant_ids' => $deletedUser['tenant_ids'],
|
2026-03-20 00:05:03 +01:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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');
|
|
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
$transactionStarted = false;
|
|
|
|
|
try {
|
|
|
|
|
$this->databaseSessionRepository->beginTransaction();
|
|
|
|
|
$transactionStarted = true;
|
|
|
|
|
|
|
|
|
|
$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) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
|
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
$createdUser = $this->userReadRepository->findByEmail($form['email']);
|
|
|
|
|
$uuid = $createdUser['uuid'] ?? null;
|
|
|
|
|
$userId = (int) ($createdUser['id'] ?? 0);
|
2026-03-09 19:54:58 +01:00
|
|
|
if ($userId <= 0) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
|
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-09 19:54:58 +01:00
|
|
|
if ($tenantIds && !$this->userAssignmentService->syncTenants($userId, $tenantIds)) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
2026-03-09 19:16:26 +01:00
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
$roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []);
|
|
|
|
|
if (!$roleIds) {
|
|
|
|
|
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
|
|
|
|
|
if ($defaultRoleId) {
|
|
|
|
|
$roleIds = [$defaultRoleId];
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 19:54:58 +01:00
|
|
|
if ($roleIds && !$this->userAssignmentService->syncRoles($userId, $roleIds)) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
$departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []);
|
|
|
|
|
if (!$departmentIds) {
|
|
|
|
|
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
|
|
|
|
|
if ($defaultDepartmentId) {
|
|
|
|
|
$departmentIds = [$defaultDepartmentId];
|
|
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-03-09 19:54:58 +01:00
|
|
|
if ($departmentIds && !$this->userAssignmentService->syncDepartments($userId, $departmentIds)) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
2026-03-09 19:16:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->databaseSessionRepository->commitTransaction();
|
|
|
|
|
$transactionStarted = false;
|
|
|
|
|
} catch (\Throwable $exception) {
|
|
|
|
|
if ($transactionStarted) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
}
|
|
|
|
|
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
$this->systemAuditService->record('admin.users.create', 'success', [
|
|
|
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
|
|
|
'target_type' => 'user',
|
2026-03-09 19:54:58 +01:00
|
|
|
'target_id' => $userId,
|
2026-03-04 15:56:58 +01:00
|
|
|
'target_uuid' => is_string($uuid) ? $uuid : '',
|
|
|
|
|
'metadata' => [
|
|
|
|
|
'assigned_tenant_ids' => $tenantIds,
|
|
|
|
|
'assigned_role_ids' => $roleIds,
|
|
|
|
|
'assigned_department_ids' => $departmentIds,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-14 08:23:04 +02:00
|
|
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_CREATED, [
|
2026-03-19 18:20:13 +01:00
|
|
|
'user_id' => $userId,
|
|
|
|
|
'uuid' => is_string($uuid) ? $uuid : '',
|
|
|
|
|
'actor_user_id' => $currentUserId,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
$this->systemAuditService->record('admin.users.update', 'success', [
|
|
|
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
|
|
|
'target_type' => 'user',
|
|
|
|
|
'target_id' => $userId,
|
|
|
|
|
'target_uuid' => (string) ($existing['uuid'] ?? ''),
|
|
|
|
|
'before' => [
|
|
|
|
|
'active' => $existing['active'] ?? null,
|
|
|
|
|
'locale' => $existing['locale'] ?? null,
|
|
|
|
|
'theme' => $existing['theme'] ?? null,
|
|
|
|
|
'primary_tenant_id' => $existing['primary_tenant_id'] ?? null,
|
|
|
|
|
],
|
|
|
|
|
'after' => [
|
|
|
|
|
'active' => $form['active'],
|
|
|
|
|
'locale' => $form['locale'],
|
|
|
|
|
'theme' => $form['theme'],
|
|
|
|
|
'primary_tenant_id' => $form['primary_tenant_id'] ?? ($existing['primary_tenant_id'] ?? null),
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
if ($activeChanged) {
|
|
|
|
|
$tenantIds = $this->extractTenantIdsFromAssignments(
|
|
|
|
|
$this->safeAssignmentsForUser($userId)
|
|
|
|
|
);
|
2026-04-14 08:23:04 +02:00
|
|
|
$eventType = ((int) $form['active'] === 1) ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED;
|
2026-03-20 00:05:03 +01:00
|
|
|
$this->eventDispatcher?->dispatch($eventType, [
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
'uuid' => (string) ($existing['uuid'] ?? ''),
|
|
|
|
|
'display_name' => trim((string) ($existing['display_name'] ?? '')),
|
|
|
|
|
'actor_user_id' => $currentUserId,
|
|
|
|
|
'tenant_ids' => $tenantIds,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
$this->systemAuditService->record($active ? 'admin.users.activate' : 'admin.users.deactivate', 'success', [
|
|
|
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
|
|
|
'target_type' => 'user',
|
|
|
|
|
'target_id' => $userId,
|
|
|
|
|
'target_uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
'before' => ['active' => $user['active'] ?? null],
|
|
|
|
|
'after' => ['active' => $active ? 1 : 0],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
$tenantIds = $this->extractTenantIdsFromAssignments(
|
|
|
|
|
$this->safeAssignmentsForUser($userId)
|
|
|
|
|
);
|
2026-04-14 08:23:04 +02:00
|
|
|
$this->eventDispatcher?->dispatch($active ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED, [
|
2026-03-20 00:05:03 +01:00
|
|
|
'user_id' => $userId,
|
|
|
|
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
|
|
|
|
'actor_user_id' => $currentUserId,
|
|
|
|
|
'tenant_ids' => $tenantIds,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
$this->systemAuditService->record('admin.users.bulk_update', 'success', [
|
|
|
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
|
|
|
'target_type' => 'user',
|
|
|
|
|
'metadata' => [
|
|
|
|
|
'action' => $active ? 'activate' : 'deactivate',
|
|
|
|
|
'count' => count($uuids),
|
|
|
|
|
'target_uuids' => $uuids,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
foreach ($uuids as $uuid) {
|
|
|
|
|
$user = $this->userReadRepository->findByUuid((string) $uuid);
|
|
|
|
|
if (!$user || !isset($user['id'])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:15:29 +01:00
|
|
|
$targetUserId = (int) $user['id'];
|
2026-03-20 00:05:03 +01:00
|
|
|
if ($targetUserId <= 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tenantIds = $this->extractTenantIdsFromAssignments(
|
|
|
|
|
$this->safeAssignmentsForUser($targetUserId)
|
|
|
|
|
);
|
2026-04-14 08:23:04 +02:00
|
|
|
$this->eventDispatcher?->dispatch($active ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED, [
|
2026-03-20 00:05:03 +01:00
|
|
|
'user_id' => $targetUserId,
|
|
|
|
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
|
|
|
|
'actor_user_id' => $currentUserId,
|
|
|
|
|
'tenant_ids' => $tenantIds,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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]];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$defaultTenantId = $this->settingsGateway->getDefaultTenantId();
|
|
|
|
|
$activeChangedAt = gmdate('Y-m-d H:i:s');
|
|
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
$transactionStarted = false;
|
|
|
|
|
try {
|
|
|
|
|
$this->databaseSessionRepository->beginTransaction();
|
|
|
|
|
$transactionStarted = true;
|
|
|
|
|
|
|
|
|
|
$created = $this->userWriteRepository->create([
|
|
|
|
|
'first_name' => $form['first_name'],
|
|
|
|
|
'last_name' => $form['last_name'],
|
|
|
|
|
'email' => $form['email'],
|
|
|
|
|
'password' => $password,
|
|
|
|
|
'locale' => I18n::$locale,
|
|
|
|
|
'totp_secret' => '',
|
refactor(settings): remove global appearance settings — tenant is sole source
Removes the global app_theme, app_theme_user and app_primary_color settings
from the admin/settings area and the underlying service/cache/API/i18n/docs
layers. The tenant columns (tenants.primary_color, default_theme,
allow_user_theme) become the single source of truth for appearance.
Branding helpers are tenant-only and fall back to a hardcoded 'light' theme
with no brand color when no tenant context is available (login, error,
pre-session pages). The APP_THEME env var is removed.
Scope:
- UI: Appearance tab gone from /admin/settings; tenant edit form drops the
global-fallback color preview.
- Service: SettingsAppGateway no longer exposes setting-backed accessors
for theme/user-theme/primary-color. Theme catalog utilities (listThemes,
isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used
across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes
'light' with a catalog fallback (no global/env lookup).
- AdminSettingsService: buildPageData, sanitization, audit snapshot, apply
step and cache payload are all stripped of the three keys. Dead helper
applyAppPrimaryColor + normalizePrimaryColor removed.
- Cache: SettingCacheService HOT_PATH_KEYS drops the three keys.
- API: /settings (GET/PUT) and /settings/public (GET) no longer expose
appearance fields; OpenAPI schemas pruned accordingly.
- Audit redaction list shortened.
- DB: db/init/init.sql seed rows removed. No migration for existing
installs — legacy rows stay inert.
- i18n + docs: setting.app_theme, setting.app_theme_user,
setting.app_primary_color removed from de+en locales; reference and
explanation docs updated.
- Tests: covering tests for the removed methods pruned; ThemeResolutionTest
rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in
UserRegistrar.php cleaned up to keep QG-006 green.
Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored).
Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions).
Reviews: code, security, acceptance all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00
|
|
|
'theme' => $this->normalizeTheme(null),
|
2026-03-09 19:16:26 +01:00
|
|
|
'primary_tenant_id' => $defaultTenantId ?: null,
|
|
|
|
|
'active' => 1,
|
|
|
|
|
'created_by' => null,
|
|
|
|
|
'active_changed_at' => $activeChangedAt,
|
|
|
|
|
'active_changed_by' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (!$created) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'error' => t('User can not be registered')];
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-03-09 19:16:26 +01:00
|
|
|
|
|
|
|
|
$createdUser = $this->userReadRepository->findByEmail($form['email']);
|
|
|
|
|
$userId = (int) ($createdUser['id'] ?? 0);
|
2026-03-09 19:54:58 +01:00
|
|
|
if ($userId <= 0) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'error' => t('User can not be registered')];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($defaultTenantId && !$this->userAssignmentService->syncTenants($userId, [$defaultTenantId])) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'error' => t('User can not be registered')];
|
|
|
|
|
}
|
|
|
|
|
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
|
|
|
|
|
if ($defaultRoleId && !$this->userAssignmentService->syncRoles($userId, [$defaultRoleId])) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'error' => t('User can not be registered')];
|
|
|
|
|
}
|
|
|
|
|
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
|
|
|
|
|
if ($defaultDepartmentId && !$this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId])) {
|
|
|
|
|
$this->rollbackQuietly();
|
|
|
|
|
return ['ok' => false, 'error' => t('User can not be registered')];
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-03-09 19:16:26 +01:00
|
|
|
|
|
|
|
|
$this->databaseSessionRepository->commitTransaction();
|
|
|
|
|
$transactionStarted = false;
|
|
|
|
|
} catch (\Throwable $exception) {
|
|
|
|
|
if ($transactionStarted) {
|
|
|
|
|
$this->rollbackQuietly();
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-03-09 19:16:26 +01:00
|
|
|
return ['ok' => false, 'error' => t('User can not be registered')];
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['ok' => true];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 08:49:40 +01:00
|
|
|
/**
|
|
|
|
|
* Self-service profile update (subset of fields, no admin-only changes).
|
|
|
|
|
*
|
|
|
|
|
* @param int $userId
|
|
|
|
|
* @param array $input
|
|
|
|
|
* @return array{ok: bool, errors?: list<string>, form?: array<string, mixed>}
|
|
|
|
|
*/
|
|
|
|
|
public function updateSelfProfile(int $userId, array $input): array
|
|
|
|
|
{
|
|
|
|
|
$existing = $this->userReadRepository->find($userId);
|
|
|
|
|
if (!$existing) {
|
|
|
|
|
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$allowedFields = [
|
|
|
|
|
'first_name', 'last_name', 'profile_description', 'job_title',
|
|
|
|
|
'phone', 'mobile', 'short_dial',
|
|
|
|
|
'address', 'postal_code', 'city', 'country', 'region',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$form = [];
|
|
|
|
|
foreach ($allowedFields as $field) {
|
|
|
|
|
$form[$field] = array_key_exists($field, $input)
|
|
|
|
|
? trim((string) $input[$field])
|
|
|
|
|
: ($existing[$field] ?? '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
if ($form['first_name'] === '') {
|
|
|
|
|
$errors[] = t('First name cannot be empty');
|
|
|
|
|
}
|
|
|
|
|
if ($form['last_name'] === '') {
|
|
|
|
|
$errors[] = t('Last name cannot be empty');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$locale = array_key_exists('locale', $input)
|
|
|
|
|
? $this->normalizeLocale($input['locale'])
|
|
|
|
|
: ($existing['locale'] ?? '');
|
|
|
|
|
$theme = array_key_exists('theme', $input)
|
|
|
|
|
? $this->normalizeTheme($input['theme'])
|
|
|
|
|
: ($existing['theme'] ?? 'light');
|
|
|
|
|
|
|
|
|
|
// Tenant switch
|
|
|
|
|
$currentTenantId = null;
|
|
|
|
|
if (array_key_exists('current_tenant_uuid', $input)) {
|
|
|
|
|
$tenantUuid = trim((string) ($input['current_tenant_uuid'] ?? ''));
|
|
|
|
|
if ($tenantUuid !== '') {
|
2026-03-04 15:56:58 +01:00
|
|
|
$tenant = $this->directoryGateway->findTenantByUuid($tenantUuid);
|
2026-02-24 08:49:40 +01:00
|
|
|
if (!$tenant || !isset($tenant['id'])) {
|
|
|
|
|
$errors[] = t('Tenant not found');
|
|
|
|
|
} else {
|
|
|
|
|
$userTenantIds = $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? [];
|
2026-03-05 11:17:42 +01:00
|
|
|
$userTenantIds = array_map(static fn (array $t): int => (int) ($t['id'] ?? 0), $userTenantIds);
|
2026-02-24 08:49:40 +01:00
|
|
|
$tenantId = (int) $tenant['id'];
|
|
|
|
|
if (!in_array($tenantId, $userTenantIds, true)) {
|
|
|
|
|
$errors[] = t('No access to this tenant');
|
|
|
|
|
} else {
|
|
|
|
|
$currentTenantId = $tenantId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($errors) {
|
|
|
|
|
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$updateData = [
|
|
|
|
|
'first_name' => $form['first_name'],
|
|
|
|
|
'last_name' => $form['last_name'],
|
|
|
|
|
'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,
|
|
|
|
|
'locale' => $locale,
|
|
|
|
|
'theme' => $theme,
|
|
|
|
|
'modified_by' => $userId,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$updated = $this->userWriteRepository->update($userId, $updateData);
|
|
|
|
|
if (!$updated) {
|
|
|
|
|
return ['ok' => false, 'errors' => [t('Profile can not be updated')]];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($currentTenantId !== null) {
|
|
|
|
|
$this->userWriteRepository->setCurrentTenant($userId, $currentTenantId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['ok' => true, 'form' => $form];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
public function dispatchAssignmentChangedIfNeeded(
|
|
|
|
|
int $userId,
|
|
|
|
|
array $beforeAssignments,
|
|
|
|
|
array $afterAssignments,
|
|
|
|
|
int $currentUserId = 0
|
|
|
|
|
): void {
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$beforeTenantIds = $this->extractTenantIdsFromAssignments($beforeAssignments);
|
|
|
|
|
$afterTenantIds = $this->extractTenantIdsFromAssignments($afterAssignments);
|
|
|
|
|
$beforeRoleIds = $this->extractAssignmentIds($beforeAssignments, 'roles');
|
|
|
|
|
$afterRoleIds = $this->extractAssignmentIds($afterAssignments, 'roles');
|
|
|
|
|
$beforeDepartmentIds = $this->extractAssignmentIds($beforeAssignments, 'departments');
|
|
|
|
|
$afterDepartmentIds = $this->extractAssignmentIds($afterAssignments, 'departments');
|
|
|
|
|
|
|
|
|
|
$tenantsChanged = $beforeTenantIds !== $afterTenantIds;
|
|
|
|
|
$rolesChanged = $beforeRoleIds !== $afterRoleIds;
|
|
|
|
|
$departmentsChanged = $beforeDepartmentIds !== $afterDepartmentIds;
|
|
|
|
|
if (!$tenantsChanged && !$rolesChanged && !$departmentsChanged) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$user = $this->userReadRepository->find($userId);
|
|
|
|
|
if (!$user) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 08:23:04 +02:00
|
|
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_ASSIGNMENT_CHANGED, [
|
2026-03-20 00:05:03 +01:00
|
|
|
'user_id' => $userId,
|
|
|
|
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
|
|
|
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
|
|
|
|
'actor_user_id' => $currentUserId,
|
|
|
|
|
'tenant_ids' => array_values(array_unique(array_merge($beforeTenantIds, $afterTenantIds))),
|
|
|
|
|
'changes' => [
|
|
|
|
|
'tenants' => ['before' => $beforeTenantIds, 'after' => $afterTenantIds],
|
|
|
|
|
'roles' => ['before' => $beforeRoleIds, 'after' => $afterRoleIds],
|
|
|
|
|
'departments' => ['before' => $beforeDepartmentIds, 'after' => $afterDepartmentIds],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
private function extractTenantIdsFromAssignments(array $assignments): array
|
|
|
|
|
{
|
|
|
|
|
return $this->extractAssignmentIds($assignments, 'tenants');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function safeAssignmentsForUser(int $userId): array
|
|
|
|
|
{
|
|
|
|
|
$assignments = $this->userAssignmentService->buildAssignmentsForUser($userId);
|
2026-03-22 14:15:29 +01:00
|
|
|
return $assignments;
|
2026-03-20 00:05:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function extractAssignmentIds(array $assignments, string $key): array
|
|
|
|
|
{
|
|
|
|
|
$rows = is_array($assignments[$key] ?? null) ? $assignments[$key] : [];
|
|
|
|
|
$ids = [];
|
|
|
|
|
foreach ($rows as $row) {
|
|
|
|
|
if (!is_array($row)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$id = (int) ($row['id'] ?? 0);
|
|
|
|
|
if ($id > 0) {
|
|
|
|
|
$ids[] = $id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$ids = array_values(array_unique($ids));
|
|
|
|
|
sort($ids);
|
|
|
|
|
|
|
|
|
|
return $ids;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
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'] ?? '')),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
private function rollbackQuietly(): void
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$this->databaseSessionRepository->rollbackTransaction();
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
// Swallow — the original exception is more important.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
private function normalizeTheme($value): string
|
|
|
|
|
{
|
2026-03-19 08:23:14 +01:00
|
|
|
$theme = is_scalar($value) ? (string) $value : null;
|
|
|
|
|
return $this->settingsGateway->normalizeTheme($theme);
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')]];
|
|
|
|
|
}
|
2026-03-06 00:44:52 +01:00
|
|
|
// Single tenant assigned → auto-select it as primary to avoid requiring the user to pick.
|
2026-02-23 12:58:19 +01:00
|
|
|
if ($primaryTenantId === 0 && count($tenantIds) === 1) {
|
|
|
|
|
return [(int) $tenantIds[0], []];
|
|
|
|
|
}
|
|
|
|
|
return [$primaryTenantId, []];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function normalizeTenantIds(array $tenantIds): array
|
|
|
|
|
{
|
|
|
|
|
return $this->userAssignmentService->normalizeTenantIds($tenantIds);
|
|
|
|
|
}
|
|
|
|
|
}
|