diff --git a/i18n/default_de.json b/i18n/default_de.json index 47460e9..763ea62 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -514,6 +514,7 @@ "Department not found": "Abteilung nicht gefunden", "Department can not be created": "Abteilung kann nicht erstellt werden", "Department can not be updated": "Abteilung kann nicht aktualisiert werden", + "Department can not be deleted while users are assigned": "Abteilung kann nicht gelöscht werden, solange Benutzer zugewiesen sind", "Delete this department?": "Diese Abteilung wirklich löschen?", "Delete department": "Abteilung löschen", "This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.", diff --git a/i18n/default_en.json b/i18n/default_en.json index 5ed9629..18aed13 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -514,6 +514,7 @@ "Department not found": "Department not found", "Department can not be created": "Department can not be created", "Department can not be updated": "Department can not be updated", + "Department can not be deleted while users are assigned": "Department can not be deleted while users are assigned", "Delete this department?": "Delete this department?", "Delete department": "Delete department", "This will permanently delete this department.": "This will permanently delete this department.", diff --git a/lib/Repository/Access/RoleRepository.php b/lib/Repository/Access/RoleRepository.php index 5f7a63a..6adc97e 100644 --- a/lib/Repository/Access/RoleRepository.php +++ b/lib/Repository/Access/RoleRepository.php @@ -119,7 +119,7 @@ class RoleRepository implements RoleRepositoryInterface return $count ? ((int) $count > 0) : false; } - public function create(array $data): mixed + public function create(array $data): int|false { return DB::insert( 'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())', diff --git a/lib/Repository/Access/RoleRepositoryInterface.php b/lib/Repository/Access/RoleRepositoryInterface.php index b24a190..4fea500 100644 --- a/lib/Repository/Access/RoleRepositoryInterface.php +++ b/lib/Repository/Access/RoleRepositoryInterface.php @@ -23,7 +23,7 @@ interface RoleRepositoryInterface public function existsByCode(string $code, int $excludeId = 0): bool; - public function create(array $data): mixed; + public function create(array $data): int|false; public function update(int $id, array $data): bool; diff --git a/lib/Repository/Org/DepartmentRepository.php b/lib/Repository/Org/DepartmentRepository.php index ed62a84..091d037 100644 --- a/lib/Repository/Org/DepartmentRepository.php +++ b/lib/Repository/Org/DepartmentRepository.php @@ -242,7 +242,7 @@ class DepartmentRepository implements DepartmentRepositoryInterface return $count ? (int) $count : 0; } - public function create(array $data): mixed + public function create(array $data): int|false { return DB::insert( 'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())', diff --git a/lib/Repository/Org/DepartmentRepositoryInterface.php b/lib/Repository/Org/DepartmentRepositoryInterface.php index 9376952..8bb3ba3 100644 --- a/lib/Repository/Org/DepartmentRepositoryInterface.php +++ b/lib/Repository/Org/DepartmentRepositoryInterface.php @@ -27,7 +27,7 @@ interface DepartmentRepositoryInterface public function countByTenantId(int $tenantId): int; - public function create(array $data): mixed; + public function create(array $data): int|false; public function update(int $id, array $data): bool; diff --git a/lib/Repository/Tenant/TenantRepository.php b/lib/Repository/Tenant/TenantRepository.php index d19c369..0b4fefb 100644 --- a/lib/Repository/Tenant/TenantRepository.php +++ b/lib/Repository/Tenant/TenantRepository.php @@ -127,7 +127,7 @@ class TenantRepository implements TenantRepositoryInterface return RepositoryArrayHelper::unwrap($row, 'tenants'); } - public function create(array $data): mixed + public function create(array $data): int|false { return DB::insert( 'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())', diff --git a/lib/Repository/Tenant/TenantRepositoryInterface.php b/lib/Repository/Tenant/TenantRepositoryInterface.php index 05af20c..c903241 100644 --- a/lib/Repository/Tenant/TenantRepositoryInterface.php +++ b/lib/Repository/Tenant/TenantRepositoryInterface.php @@ -19,7 +19,7 @@ interface TenantRepositoryInterface public function findByUuid(string $uuid): ?array; - public function create(array $data): mixed; + public function create(array $data): int|false; public function update(int $id, array $data): bool; diff --git a/lib/Service/Access/RoleService.php b/lib/Service/Access/RoleService.php index f2a8eee..2771335 100644 --- a/lib/Service/Access/RoleService.php +++ b/lib/Service/Access/RoleService.php @@ -43,10 +43,11 @@ class RoleService public function createFromAdmin(array $input, int $currentUserId = 0): array { $form = $this->sanitizeBase($input); - $errors = $this->validateBase($form, 0); + $errors = $this->validateBase($form); + $warnings = $this->warningsForCode($form, 0); if ($errors) { - return ['ok' => false, 'errors' => $errors, 'form' => $form]; + return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } $createdId = $this->roleRepository->create([ @@ -74,16 +75,17 @@ class RoleService 'metadata' => ['active' => $form['active']], ]); - return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; + return ['ok' => true, 'form' => $form, 'warnings' => $warnings, 'uuid' => $uuid, 'id' => (int) $createdId]; } public function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array { $form = $this->sanitizeBase($input); - $errors = $this->validateBase($form, $roleId); + $errors = $this->validateBase($form); + $warnings = $this->warningsForCode($form, $roleId); if ($errors) { - return ['ok' => false, 'errors' => $errors, 'form' => $form]; + return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } $existingBefore = $this->roleRepository->find($roleId) ?? []; @@ -108,7 +110,7 @@ class RoleService 'after' => ['active' => $form['active']], ]); - return ['ok' => true, 'form' => $form]; + return ['ok' => true, 'form' => $form, 'warnings' => $warnings]; } public function deleteByUuid(string $uuid): array @@ -150,17 +152,25 @@ class RoleService ]; } - private function validateBase(array $form, int $excludeId): array + private function validateBase(array $form): array { $errors = []; if ($form['description'] === '') { $errors[] = t('Description cannot be empty'); } + return $errors; + } + + // Duplicate codes are a warning, not a hard error — codes are optional identifiers, + // not unique keys, so duplicates are allowed but surfaced to the user. + private function warningsForCode(array $form, int $excludeId): array + { + $warnings = []; $code = $form['code'] ?? ''; if ($code !== '' && $this->roleRepository->existsByCode($code, $excludeId)) { - $errors[] = t('Role code already exists'); + $warnings[] = t('Role code already exists'); } - return $errors; + return $warnings; } private function normalizeActive($value): int diff --git a/lib/Service/Org/DepartmentService.php b/lib/Service/Org/DepartmentService.php index abd39f5..2ecbbfd 100644 --- a/lib/Service/Org/DepartmentService.php +++ b/lib/Service/Org/DepartmentService.php @@ -235,14 +235,22 @@ class DepartmentService return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $deleted = $this->departmentRepository->delete((int) $department['id']); + $departmentId = (int) $department['id']; + $userDeptRepo = $this->userServicesFactory->createUserDepartmentRepository(); + $counts = $userDeptRepo->countUsersByDepartmentIds([$departmentId]); + $userCount = (int) ($counts[$departmentId] ?? 0); + if ($userCount > 0) { + return ['ok' => false, 'status' => 409, 'error' => 'department_has_users', 'user_count' => $userCount]; + } + + $deleted = $this->departmentRepository->delete($departmentId); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } $this->systemAuditService->record('admin.departments.delete', 'success', [ 'target_type' => 'department', - 'target_id' => (int) $department['id'], + 'target_id' => $departmentId, 'target_uuid' => (string) ($department['uuid'] ?? ''), ]); diff --git a/lib/Service/Settings/AdminSettingsService.php b/lib/Service/Settings/AdminSettingsService.php index cd08a77..c2d71ed 100644 --- a/lib/Service/Settings/AdminSettingsService.php +++ b/lib/Service/Settings/AdminSettingsService.php @@ -124,42 +124,81 @@ class AdminSettingsService public function updateFromAdmin(array $post): array { - $defaultTenantId = (int) ($post['default_tenant_id'] ?? 0); - $defaultRoleId = (int) ($post['default_role_id'] ?? 0); - $defaultDepartmentId = (int) ($post['default_department_id'] ?? 0); - $appTitle = trim((string) ($post['app_title'] ?? '')); - $appLocale = trim((string) ($post['app_locale'] ?? '')); - $appTheme = trim((string) ($post['app_theme'] ?? '')); - $appThemeUser = isset($post['app_theme_user']); - $appRegistration = isset($post['app_registration']); - $apiTokenDefaultTtlDays = (int) ($post['api_token_default_ttl_days'] ?? 0); - $apiTokenMaxTtlDays = (int) ($post['api_token_max_ttl_days'] ?? 0); - $userInactivityDeactivateDays = (int) ($post['user_inactivity_deactivate_days'] ?? 0); - $userInactivityDeleteDays = (int) ($post['user_inactivity_delete_days'] ?? 0); - $sessionIdleTimeoutMinutes = (int) ($post['session_idle_timeout_minutes'] ?? 0); - $sessionAbsoluteTimeoutHours = (int) ($post['session_absolute_timeout_hours'] ?? 0); - $microsoftAutoRememberDefault = isset($post['microsoft_auto_remember_default']); - $rememberTokenLifetimeDays = (int) ($post['remember_token_lifetime_days'] ?? 0); - $apiCorsAllowedOrigins = $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? ''); - $systemAuditEnabled = isset($post['system_audit_enabled']); - $systemAuditRetentionDays = (int) ($post['system_audit_retention_days'] ?? 0); - $frontendTelemetryEnabled = isset($post['frontend_telemetry_enabled']); - $frontendTelemetrySampleRate = $this->normalizeScalarText($post['frontend_telemetry_sample_rate'] ?? ''); - $frontendTelemetryAllowedEvents = $post['frontend_telemetry_allowed_events'] ?? []; - $appPrimaryColor = $this->normalizePrimaryColor($post['app_primary_color'] ?? ''); - $smtpHost = trim((string) ($post['smtp_host'] ?? '')); - $smtpPort = (int) ($post['smtp_port'] ?? 0); - $smtpUser = trim((string) ($post['smtp_user'] ?? '')); - $smtpPassword = (string) ($post['smtp_password'] ?? ''); - $smtpSecure = trim((string) ($post['smtp_secure'] ?? '')); - $smtpFrom = trim((string) ($post['smtp_from'] ?? '')); - $smtpFromName = trim((string) ($post['smtp_from_name'] ?? '')); - $microsoftSharedClientId = trim((string) ($post['microsoft_shared_client_id'] ?? '')); - $microsoftSharedClientSecret = (string) ($post['microsoft_shared_client_secret'] ?? ''); - $microsoftAuthority = trim((string) ($post['microsoft_authority'] ?? '')); - + $input = $this->sanitizeAdminInput($post); + $beforeAudit = $this->captureAuditSnapshot(); $errors = []; - $beforeAudit = [ + + $this->applyDefaultsAndAppSettings($input); + array_push($errors, ...$this->applyApiAndLifecycleSettings($input)); + array_push($errors, ...$this->applySessionAndLoginSettings($input)); + array_push($errors, ...$this->applyAuditAndTelemetrySettings($input)); + array_push($errors, ...$this->applyAppPrimaryColor($input)); + $this->applySmtpSettings($input); + array_push($errors, ...$this->applyMicrosoftSettings($input)); + $this->updateSettingsCache($input); + + $afterAudit = $this->captureAuditSnapshot(); + $this->recordAudit($errors, $beforeAudit, $afterAudit); + + return ['errors' => $errors]; + } + + private function sanitizeAdminInput(array $post): array + { + return array_merge( + $this->sanitizeAppAndPolicyInput($post), + $this->sanitizeIntegrationInput($post), + ); + } + + private function sanitizeAppAndPolicyInput(array $post): array + { + return [ + 'default_tenant_id' => (int) ($post['default_tenant_id'] ?? 0), + 'default_role_id' => (int) ($post['default_role_id'] ?? 0), + 'default_department_id' => (int) ($post['default_department_id'] ?? 0), + 'app_title' => trim((string) ($post['app_title'] ?? '')), + 'app_locale' => trim((string) ($post['app_locale'] ?? '')), + 'app_theme' => trim((string) ($post['app_theme'] ?? '')), + 'app_theme_user' => isset($post['app_theme_user']), + 'app_registration' => isset($post['app_registration']), + 'app_primary_color' => $this->normalizePrimaryColor($post['app_primary_color'] ?? ''), + 'api_token_default_ttl_days' => (int) ($post['api_token_default_ttl_days'] ?? 0), + 'api_token_max_ttl_days' => (int) ($post['api_token_max_ttl_days'] ?? 0), + 'api_cors_allowed_origins' => $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? ''), + 'user_inactivity_deactivate_days' => (int) ($post['user_inactivity_deactivate_days'] ?? 0), + 'user_inactivity_delete_days' => (int) ($post['user_inactivity_delete_days'] ?? 0), + 'session_idle_timeout_minutes' => (int) ($post['session_idle_timeout_minutes'] ?? 0), + 'session_absolute_timeout_hours' => (int) ($post['session_absolute_timeout_hours'] ?? 0), + 'microsoft_auto_remember_default' => isset($post['microsoft_auto_remember_default']), + 'remember_token_lifetime_days' => (int) ($post['remember_token_lifetime_days'] ?? 0), + ]; + } + + private function sanitizeIntegrationInput(array $post): array + { + return [ + 'system_audit_enabled' => isset($post['system_audit_enabled']), + 'system_audit_retention_days' => (int) ($post['system_audit_retention_days'] ?? 0), + 'frontend_telemetry_enabled' => isset($post['frontend_telemetry_enabled']), + 'frontend_telemetry_sample_rate' => $this->normalizeScalarText($post['frontend_telemetry_sample_rate'] ?? ''), + 'frontend_telemetry_allowed_events' => $post['frontend_telemetry_allowed_events'] ?? [], + 'smtp_host' => trim((string) ($post['smtp_host'] ?? '')), + 'smtp_port' => (int) ($post['smtp_port'] ?? 0), + 'smtp_user' => trim((string) ($post['smtp_user'] ?? '')), + 'smtp_password' => (string) ($post['smtp_password'] ?? ''), + 'smtp_secure' => trim((string) ($post['smtp_secure'] ?? '')), + 'smtp_from' => trim((string) ($post['smtp_from'] ?? '')), + 'smtp_from_name' => trim((string) ($post['smtp_from_name'] ?? '')), + 'microsoft_shared_client_id' => trim((string) ($post['microsoft_shared_client_id'] ?? '')), + 'microsoft_shared_client_secret' => (string) ($post['microsoft_shared_client_secret'] ?? ''), + 'microsoft_authority' => trim((string) ($post['microsoft_authority'] ?? '')), + ]; + } + + private function captureAuditSnapshot(): array + { + return [ 'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(), 'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(), 'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(), @@ -182,155 +221,153 @@ class AdminSettingsService 'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(), 'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(), ]; + } - $this->settingsDefaultsGateway->setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null); - $this->settingsDefaultsGateway->setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null); - $this->settingsDefaultsGateway->setDefaultDepartmentId($defaultDepartmentId > 0 ? $defaultDepartmentId : null); - $this->settingsAppGateway->setAppTitle($appTitle); - $this->settingsAppGateway->setAppLocale($appLocale); - $this->settingsAppGateway->setAppTheme($appTheme); - $this->settingsAppGateway->setUserThemeAllowed($appThemeUser); - $this->settingsAppGateway->setRegistrationEnabled($appRegistration); + private function applyDefaultsAndAppSettings(array $input): void + { + $this->settingsDefaultsGateway->setDefaultTenantId($input['default_tenant_id'] > 0 ? $input['default_tenant_id'] : null); + $this->settingsDefaultsGateway->setDefaultRoleId($input['default_role_id'] > 0 ? $input['default_role_id'] : null); + $this->settingsDefaultsGateway->setDefaultDepartmentId($input['default_department_id'] > 0 ? $input['default_department_id'] : null); + $this->settingsAppGateway->setAppTitle($input['app_title']); + $this->settingsAppGateway->setAppLocale($input['app_locale']); + $this->settingsAppGateway->setAppTheme($input['app_theme']); + $this->settingsAppGateway->setUserThemeAllowed($input['app_theme_user']); + $this->settingsAppGateway->setRegistrationEnabled($input['app_registration']); + } - $apiTokenMaxTtlOk = $this->settingsApiPolicyGateway->setApiTokenMaxTtlDays($apiTokenMaxTtlDays > 0 ? $apiTokenMaxTtlDays : null); - if (!$apiTokenMaxTtlOk) { + /** @return list */ + private function applyApiAndLifecycleSettings(array $input): array + { + $errors = []; + + if (!$this->settingsApiPolicyGateway->setApiTokenMaxTtlDays($input['api_token_max_ttl_days'] > 0 ? $input['api_token_max_ttl_days'] : null)) { $errors[] = ['message' => 'API token max lifetime is invalid', 'key' => 'api_token_max_ttl_invalid']; } - $apiTokenTtlOk = $this->settingsApiPolicyGateway->setApiTokenDefaultTtlDays($apiTokenDefaultTtlDays > 0 ? $apiTokenDefaultTtlDays : null); - if (!$apiTokenTtlOk) { + if (!$this->settingsApiPolicyGateway->setApiTokenDefaultTtlDays($input['api_token_default_ttl_days'] > 0 ? $input['api_token_default_ttl_days'] : null)) { $errors[] = ['message' => 'API token default lifetime is invalid', 'key' => 'api_token_default_ttl_invalid']; } - - $userDeactivateOk = $this->settingsUserLifecycleGateway->setUserInactivityDeactivateDays($userInactivityDeactivateDays); - if (!$userDeactivateOk) { - $errors[] = [ - 'message' => 'User inactivity deactivation period is invalid', - 'key' => 'user_inactivity_deactivate_days_invalid', - ]; + if (!$this->settingsApiPolicyGateway->setApiCorsAllowedOrigins($input['api_cors_allowed_origins'])) { + $errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_invalid']; } - - if ($userInactivityDeactivateDays > 0) { - $userDeleteOk = $this->settingsUserLifecycleGateway->setUserInactivityDeleteDays($userInactivityDeleteDays); - if (!$userDeleteOk) { - $errors[] = [ - 'message' => 'User inactivity deletion period is invalid', - 'key' => 'user_inactivity_delete_days_invalid', - ]; + if (!$this->settingsUserLifecycleGateway->setUserInactivityDeactivateDays($input['user_inactivity_deactivate_days'])) { + $errors[] = ['message' => 'User inactivity deactivation period is invalid', 'key' => 'user_inactivity_deactivate_days_invalid']; + } + if ($input['user_inactivity_deactivate_days'] > 0) { + if (!$this->settingsUserLifecycleGateway->setUserInactivityDeleteDays($input['user_inactivity_delete_days'])) { + $errors[] = ['message' => 'User inactivity deletion period is invalid', 'key' => 'user_inactivity_delete_days_invalid']; } } else { - if ($userInactivityDeleteDays > 0) { - $errors[] = [ - 'message' => 'Auto-delete is ignored while auto-deactivate is disabled', - 'key' => 'user_inactivity_delete_requires_deactivate', - ]; + if ($input['user_inactivity_delete_days'] > 0) { + $errors[] = ['message' => 'Auto-delete is ignored while auto-deactivate is disabled', 'key' => 'user_inactivity_delete_requires_deactivate']; } $this->settingsUserLifecycleGateway->setUserInactivityDeleteDays(0); } - $sessionIdleOk = $this->settingsSessionGateway->setSessionIdleTimeoutMinutes( - $sessionIdleTimeoutMinutes > 0 ? $sessionIdleTimeoutMinutes : null - ); - if (!$sessionIdleOk) { + return $errors; + } + + /** @return list */ + private function applySessionAndLoginSettings(array $input): array + { + $errors = []; + + if (!$this->settingsSessionGateway->setSessionIdleTimeoutMinutes($input['session_idle_timeout_minutes'] > 0 ? $input['session_idle_timeout_minutes'] : null)) { $errors[] = ['message' => 'Session idle timeout is invalid', 'key' => 'session_idle_timeout_invalid']; } - - $sessionAbsoluteOk = $this->settingsSessionGateway->setSessionAbsoluteTimeoutHours( - $sessionAbsoluteTimeoutHours > 0 ? $sessionAbsoluteTimeoutHours : null - ); - if (!$sessionAbsoluteOk) { + if (!$this->settingsSessionGateway->setSessionAbsoluteTimeoutHours($input['session_absolute_timeout_hours'] > 0 ? $input['session_absolute_timeout_hours'] : null)) { $errors[] = ['message' => 'Session absolute timeout is invalid', 'key' => 'session_absolute_timeout_invalid']; } - - $this->settingsLoginPersistenceGateway->setMicrosoftAutoRememberDefault($microsoftAutoRememberDefault); - - $rememberTtlOk = $this->settingsLoginPersistenceGateway->setRememberTokenLifetimeDays( - $rememberTokenLifetimeDays > 0 ? $rememberTokenLifetimeDays : null - ); - if (!$rememberTtlOk) { + $this->settingsLoginPersistenceGateway->setMicrosoftAutoRememberDefault($input['microsoft_auto_remember_default']); + if (!$this->settingsLoginPersistenceGateway->setRememberTokenLifetimeDays($input['remember_token_lifetime_days'] > 0 ? $input['remember_token_lifetime_days'] : null)) { $errors[] = ['message' => 'Remember token lifetime is invalid', 'key' => 'remember_token_lifetime_days_invalid']; } - $apiCorsOriginsOk = $this->settingsApiPolicyGateway->setApiCorsAllowedOrigins($apiCorsAllowedOrigins); - if (!$apiCorsOriginsOk) { - $errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_invalid']; - } + return $errors; + } - $systemAuditEnabledOk = $this->settingsSystemAuditGateway->setSystemAuditEnabled($systemAuditEnabled); - if (!$systemAuditEnabledOk) { + /** @return list */ + private function applyAuditAndTelemetrySettings(array $input): array + { + $errors = []; + + if (!$this->settingsSystemAuditGateway->setSystemAuditEnabled($input['system_audit_enabled'])) { $errors[] = ['message' => 'System audit setting is invalid', 'key' => 'system_audit_enabled_invalid']; } - - $systemAuditRetentionOk = $this->settingsSystemAuditGateway->setSystemAuditRetentionDays( - $systemAuditRetentionDays > 0 ? $systemAuditRetentionDays : null - ); - if (!$systemAuditRetentionOk) { + if (!$this->settingsSystemAuditGateway->setSystemAuditRetentionDays($input['system_audit_retention_days'] > 0 ? $input['system_audit_retention_days'] : null)) { $errors[] = ['message' => 'System audit retention is invalid', 'key' => 'system_audit_retention_invalid']; } - - $frontendTelemetryEnabledOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetryEnabled($frontendTelemetryEnabled); - if (!$frontendTelemetryEnabledOk) { + if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetryEnabled($input['frontend_telemetry_enabled'])) { $errors[] = ['message' => 'Frontend telemetry enabled setting is invalid', 'key' => 'frontend_telemetry_enabled_invalid']; } - $frontendTelemetryRateValue = null; - if ($frontendTelemetrySampleRate !== '') { - $frontendTelemetryRateValue = is_numeric($frontendTelemetrySampleRate) - ? (float) $frontendTelemetrySampleRate + $rateValue = null; + if ($input['frontend_telemetry_sample_rate'] !== '') { + $rateValue = is_numeric($input['frontend_telemetry_sample_rate']) + ? (float) $input['frontend_telemetry_sample_rate'] : NAN; } - $frontendTelemetrySampleRateOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetrySampleRate($frontendTelemetryRateValue); - if (!$frontendTelemetrySampleRateOk) { + if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetrySampleRate($rateValue)) { $errors[] = ['message' => 'Frontend telemetry sample rate is invalid', 'key' => 'frontend_telemetry_sample_rate_invalid']; } - - $frontendTelemetryAllowedEventsOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetryAllowedEvents($frontendTelemetryAllowedEvents); - if (!$frontendTelemetryAllowedEventsOk) { + if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetryAllowedEvents($input['frontend_telemetry_allowed_events'])) { $errors[] = ['message' => 'Frontend telemetry allowed events are invalid', 'key' => 'frontend_telemetry_allowed_events_invalid']; } - $primaryOk = $this->settingsAppGateway->setAppPrimaryColor($appPrimaryColor); - if (!$primaryOk) { - $appPrimaryColor = ''; - $errors[] = ['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid']; - } + return $errors; + } - $this->settingsSmtpGateway->setSmtpHost($smtpHost); - $this->settingsSmtpGateway->setSmtpPort($smtpPort > 0 ? $smtpPort : null); - $this->settingsSmtpGateway->setSmtpUser($smtpUser); - if (trim($smtpPassword) !== '') { - $this->settingsSmtpGateway->setSmtpPassword($smtpPassword); + /** @return list */ + private function applyAppPrimaryColor(array &$input): array + { + if (!$this->settingsAppGateway->setAppPrimaryColor($input['app_primary_color'])) { + $input['app_primary_color'] = ''; + return [['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid']]; } - $this->settingsSmtpGateway->setSmtpSecure($smtpSecure); - $this->settingsSmtpGateway->setSmtpFrom($smtpFrom); - $this->settingsSmtpGateway->setSmtpFromName($smtpFromName); + return []; + } - $msClientIdOk = $this->settingsMicrosoftGateway->setMicrosoftSharedClientId($microsoftSharedClientId); - if (!$msClientIdOk) { + private function applySmtpSettings(array $input): void + { + $this->settingsSmtpGateway->setSmtpHost($input['smtp_host']); + $this->settingsSmtpGateway->setSmtpPort($input['smtp_port'] > 0 ? $input['smtp_port'] : null); + $this->settingsSmtpGateway->setSmtpUser($input['smtp_user']); + if (trim($input['smtp_password']) !== '') { + $this->settingsSmtpGateway->setSmtpPassword($input['smtp_password']); + } + $this->settingsSmtpGateway->setSmtpSecure($input['smtp_secure']); + $this->settingsSmtpGateway->setSmtpFrom($input['smtp_from']); + $this->settingsSmtpGateway->setSmtpFromName($input['smtp_from_name']); + } + + /** @return list */ + private function applyMicrosoftSettings(array $input): array + { + $errors = []; + + if (!$this->settingsMicrosoftGateway->setMicrosoftSharedClientId($input['microsoft_shared_client_id'])) { $errors[] = ['message' => 'Microsoft client ID is invalid', 'key' => 'microsoft_client_id_invalid']; } - - $msAuthorityOk = $this->settingsMicrosoftGateway->setMicrosoftAuthority($microsoftAuthority); - if (!$msAuthorityOk) { + if (!$this->settingsMicrosoftGateway->setMicrosoftAuthority($input['microsoft_authority'])) { $errors[] = ['message' => 'Microsoft authority is invalid', 'key' => 'microsoft_authority_invalid']; } - - if (trim($microsoftSharedClientSecret) !== '') { - $msSecretOk = $this->settingsMicrosoftGateway->setMicrosoftSharedClientSecret($microsoftSharedClientSecret); - if (!$msSecretOk) { - $errors[] = [ - 'message' => 'Microsoft client secret could not be encrypted', - 'key' => 'microsoft_client_secret_invalid', - ]; + if (trim($input['microsoft_shared_client_secret']) !== '') { + if (!$this->settingsMicrosoftGateway->setMicrosoftSharedClientSecret($input['microsoft_shared_client_secret'])) { + $errors[] = ['message' => 'Microsoft client secret could not be encrypted', 'key' => 'microsoft_client_secret_invalid']; } } + return $errors; + } + + private function updateSettingsCache(array $input): void + { $this->settingCacheService->update([ - 'app_title' => $appTitle !== '' ? $appTitle : null, - 'app_locale' => $appLocale !== '' ? $appLocale : null, - 'app_theme' => $appTheme !== '' ? $appTheme : null, - 'app_theme_user' => $appThemeUser ? '1' : '0', - 'app_registration' => $appRegistration ? '1' : '0', - 'app_primary_color' => $appPrimaryColor !== '' ? $appPrimaryColor : null, + 'app_title' => $input['app_title'] !== '' ? $input['app_title'] : null, + 'app_locale' => $input['app_locale'] !== '' ? $input['app_locale'] : null, + 'app_theme' => $input['app_theme'] !== '' ? $input['app_theme'] : null, + 'app_theme_user' => $input['app_theme_user'] ? '1' : '0', + 'app_registration' => $input['app_registration'] ? '1' : '0', + 'app_primary_color' => $input['app_primary_color'] !== '' ? $input['app_primary_color'] : null, 'api_token_default_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(), 'api_token_max_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(), 'api_cors_allowed_origins' => $this->settingsMetadataGateway->getValue(SettingKeys::API_CORS_ALLOWED_ORIGINS_KEY), @@ -338,39 +375,17 @@ class AdminSettingsService 'frontend_telemetry_sample_rate' => (string) $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(), 'frontend_telemetry_allowed_events' => implode(',', $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents()), ]); + } - $afterAudit = [ - 'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(), - 'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(), - 'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(), - 'app_locale' => $this->settingsAppGateway->getAppLocale(), - 'app_theme' => $this->settingsAppGateway->getAppTheme(), - 'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(), - 'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(), - 'app_primary_color' => $this->settingsAppGateway->getAppPrimaryColor(), - 'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(), - 'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(), - 'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(), - 'user_inactivity_delete_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeleteDays(), - 'session_idle_timeout_minutes' => $this->settingsSessionGateway->getSessionIdleTimeoutMinutes(), - 'session_absolute_timeout_hours' => $this->settingsSessionGateway->getSessionAbsoluteTimeoutHours(), - 'microsoft_auto_remember_default' => $this->settingsLoginPersistenceGateway->isMicrosoftAutoRememberDefault(), - 'remember_token_lifetime_days' => $this->settingsLoginPersistenceGateway->getRememberTokenLifetimeDays(), - 'system_audit_enabled' => $this->settingsSystemAuditGateway->isSystemAuditEnabled(), - 'system_audit_retention_days' => $this->settingsSystemAuditGateway->getSystemAuditRetentionDays(), - 'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled(), - 'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(), - 'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(), - ]; - + /** @param list $errors */ + private function recordAudit(array $errors, array $beforeAudit, array $afterAudit): void + { $outcome = $errors === [] ? 'success' : 'failed'; $errorKeys = []; - if ($errors !== []) { - foreach ($errors as $error) { - $key = trim((string) $error['key']); - if ($key !== '') { - $errorKeys[] = $key; - } + foreach ($errors as $error) { + $key = trim((string) $error['key']); + if ($key !== '') { + $errorKeys[] = $key; } } @@ -380,8 +395,6 @@ class AdminSettingsService 'error_code' => $errors === [] ? null : 'validation_error', 'metadata' => $errors === [] ? [] : ['error_keys' => $errorKeys], ]); - - return ['errors' => $errors]; } private function normalizeScalarText(mixed $value): string diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php index a31f13d..403b3cf 100644 --- a/pages/admin/departments/create().php +++ b/pages/admin/departments/create().php @@ -56,7 +56,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('description')) { +if ($request->isMethod('POST')) { $selectedTenantId = $request->bodyInt('tenant_id'); $selectedTenantIds = $directoryScopeGateway->filterTenantIdsForUser([$selectedTenantId], $currentUserId); $selectedTenantId = (int) ($selectedTenantIds[0] ?? 0); diff --git a/pages/admin/departments/delete($id).php b/pages/admin/departments/delete($id).php index f824f96..c54c6f5 100644 --- a/pages/admin/departments/delete($id).php +++ b/pages/admin/departments/delete($id).php @@ -1,5 +1,6 @@ findByUuid($uuid); if (!$department) { + if (Request::wantsJson()) { + http_response_code(404); + Router::json(['error' => 'not_found']); + return; + } Flash::error('Department not found', 'admin/departments', 'department_not_found'); Router::redirect('admin/departments'); return; @@ -30,13 +36,29 @@ $decision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABIL 'target_department_id' => $departmentId, ]); if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } Router::redirect('error/forbidden'); return; } $result = app(\MintyPHP\Service\Org\DepartmentService::class)->deleteByUuid($uuid); if (!($result['ok'] ?? false)) { - Flash::error('Department not found', 'admin/departments', 'department_not_found'); + $error = $result['error'] ?? 'not_found'; + $status = $result['status'] ?? 500; + if (Request::wantsJson()) { + http_response_code($status); + Router::json(['error' => $error]); + return; + } + if ($error === 'department_has_users') { + Flash::error(t('Department can not be deleted while users are assigned'), 'admin/departments', 'department_has_users'); + } else { + Flash::error(t('Department not found'), 'admin/departments', 'department_not_found'); + } Router::redirect('admin/departments'); return; } @@ -45,5 +67,11 @@ if ($currentUserId > 0) { app(\MintyPHP\Service\Auth\AuthService::class)->loadTenantDataIntoSession($currentUserId); } +if (Request::wantsJson()) { + http_response_code(204); + Router::json([]); + return; +} + Flash::success('Department deleted', 'admin/departments', 'department_deleted'); Router::redirect('admin/departments'); diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index 5ca9d24..50bd8ce 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -87,7 +87,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('description')) { +if ($request->isMethod('POST')) { $submitDecision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, 'target_department_id' => $departmentId, diff --git a/pages/admin/permissions/create().php b/pages/admin/permissions/create().php index 2dc50ec..7c08260 100644 --- a/pages/admin/permissions/create().php +++ b/pages/admin/permissions/create().php @@ -39,7 +39,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('key')) { +if ($request->isMethod('POST')) { $result = app(\MintyPHP\Service\Access\PermissionService::class)->createFromAdmin($request->bodyAll()); $form = $result['form'] ?? $form; $errorBag->merge($result['errors'] ?? []); diff --git a/pages/admin/permissions/delete($id).php b/pages/admin/permissions/delete($id).php index 86384f8..2b6024d 100644 --- a/pages/admin/permissions/delete($id).php +++ b/pages/admin/permissions/delete($id).php @@ -1,5 +1,6 @@ authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE, [ 'actor_user_id' => $currentUserId, ]); if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } Guard::deny(); } @@ -30,18 +39,31 @@ $affectedUserIds = app(\MintyPHP\Repository\Access\UserRoleRepository::class)->l $result = app(PermissionService::class)->deleteById($id); if (!($result['ok'] ?? false)) { - $error = (string) ($result['error'] ?? ''); + $error = (string) ($result['error'] ?? 'not_found'); + $status = $result['status'] ?? 500; + if (Request::wantsJson()) { + http_response_code($status); + Router::json(['error' => $error]); + return; + } if ($error === 'system_permission_protected') { Flash::error('System permission can not be deleted', 'admin/permissions', 'permission_system_protected'); } else { Flash::error('Permission not found', 'admin/permissions', 'permission_not_found'); } Router::redirect('admin/permissions'); + return; } if ($affectedUserIds) { app(\MintyPHP\Repository\User\UserWriteRepository::class)->bumpAuthzVersionByUserIds($affectedUserIds); } +if (Request::wantsJson()) { + http_response_code(204); + Router::json([]); + return; +} + Flash::success('Permission deleted', 'admin/permissions', 'permission_deleted'); Router::redirect('admin/permissions'); diff --git a/pages/admin/permissions/edit($id).php b/pages/admin/permissions/edit($id).php index 04003a0..31f26fc 100644 --- a/pages/admin/permissions/edit($id).php +++ b/pages/admin/permissions/edit($id).php @@ -54,7 +54,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('key')) { +if ($request->isMethod('POST')) { $submitDecision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, 'target_permission_id' => $id, diff --git a/pages/admin/roles/create().php b/pages/admin/roles/create().php index 6b40ebe..0d2f2a2 100644 --- a/pages/admin/roles/create().php +++ b/pages/admin/roles/create().php @@ -26,6 +26,7 @@ if (!$decision->isAllowed()) { $errorBag = formErrors(); $errors = []; +$warnings = []; $form = [ 'description' => '', 'code' => '', @@ -42,7 +43,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('description')) { +if ($request->isMethod('POST')) { $dbSession = app(\MintyPHP\Repository\Support\DatabaseSessionRepository::class); $transactionStarted = false; @@ -52,6 +53,7 @@ if ($request->hasBody('description')) { $result = app(\MintyPHP\Service\Access\RoleService::class)->createFromAdmin($request->bodyAll(), $currentUserId); $form = $result['form'] ?? $form; + $warnings = $result['warnings'] ?? []; $errorBag->merge($result['errors'] ?? []); $selectedPermissionIds = $request->body('permission_ids', $selectedPermissionIds); if (!is_array($selectedPermissionIds)) { @@ -75,15 +77,24 @@ if ($request->hasBody('description')) { $action = (string) $request->body('action', 'create'); if ($action === 'create_close') { + if ($warnings) { + Flash::info(implode(' ', $warnings), $closeTarget, 'role_warning'); + } Flash::success('Role created', $closeTarget, 'role_created'); Router::redirect($closeTarget); } else { $uuid = (string) ($result['uuid'] ?? ''); if ($uuid !== '') { $target = requestPathWithReturnTarget("admin/roles/edit/{$uuid}", $returnTarget); + if ($warnings) { + Flash::info(implode(' ', $warnings), $target, 'role_warning'); + } Flash::success('Role created', $target, 'role_created'); Router::redirect($target); } else { + if ($warnings) { + Flash::info(implode(' ', $warnings), $closeTarget, 'role_warning'); + } Flash::success('Role created', $closeTarget, 'role_created'); Router::redirect($closeTarget); } diff --git a/pages/admin/roles/create(default).phtml b/pages/admin/roles/create(default).phtml index fdb3be5..f9c9a8a 100644 --- a/pages/admin/roles/create(default).phtml +++ b/pages/admin/roles/create(default).phtml @@ -42,6 +42,15 @@ $validationSummaryErrors = $validationSummaryErrors ?? ($errors ?? []); require templatePath('partials/app-details-validation-summary.phtml'); ?> + +
+
    + +
  • + +
+
+ authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE, [ 'actor_user_id' => $currentUserId, ]); if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } Guard::deny(); } @@ -35,18 +44,31 @@ if ($role) { $result = app(\MintyPHP\Service\Access\RoleService::class)->deleteByUuid($uuid); if (!($result['ok'] ?? false)) { - $error = $result['error'] ?? 'unknown'; + $error = $result['error'] ?? 'not_found'; + $status = $result['status'] ?? 500; + if (Request::wantsJson()) { + http_response_code($status); + Router::json(['error' => $error]); + return; + } if ($error === 'admin_role_protected') { Flash::error('The Admin role cannot be deleted', 'admin/roles', 'admin_role_protected'); } else { Flash::error('Role not found', 'admin/roles', 'role_not_found'); } Router::redirect('admin/roles'); + return; } if ($affectedUserIds) { app(\MintyPHP\Repository\User\UserWriteRepository::class)->bumpAuthzVersionByUserIds($affectedUserIds); } +if (Request::wantsJson()) { + http_response_code(204); + Router::json([]); + return; +} + Flash::success('Role deleted', 'admin/roles', 'role_deleted'); Router::redirect('admin/roles'); diff --git a/pages/admin/roles/edit($id).php b/pages/admin/roles/edit($id).php index a076cb2..3f18981 100644 --- a/pages/admin/roles/edit($id).php +++ b/pages/admin/roles/edit($id).php @@ -44,6 +44,7 @@ app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($role); $errorBag = formErrors(); $errors = []; +$warnings = []; $form = $role; $permissions = $permissionRepository->listActive(); $selectedPermissionIds = $rolePermissionRepository->listPermissionIdsByRoleId($roleId); @@ -58,7 +59,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('description')) { +if ($request->isMethod('POST')) { $submitDecision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, 'target_role_id' => $roleId, @@ -68,6 +69,7 @@ if ($request->hasBody('description')) { } $result = app(\MintyPHP\Service\Access\RoleService::class)->updateFromAdmin($roleId, $request->bodyAll(), $currentUserId); $form = $result['form'] ?? $form; + $warnings = $result['warnings'] ?? []; $errorBag->merge($result['errors'] ?? []); $selectedPermissionIds = $request->body('permission_ids', $selectedPermissionIds); if (!is_array($selectedPermissionIds)) { @@ -108,9 +110,15 @@ if ($request->hasBody('description')) { if ($syncSucceeded) { $action = (string) $request->body('action', 'save'); if ($action === 'save_close') { + if ($warnings) { + Flash::info(implode(' ', $warnings), $closeTarget, 'role_warning'); + } Flash::success('Role updated', $closeTarget, 'role_updated'); Router::redirect($closeTarget); } else { + if ($warnings) { + Flash::info(implode(' ', $warnings), $editTarget, 'role_warning'); + } Flash::success('Role updated', $editTarget, 'role_updated'); Router::redirect($editTarget); } diff --git a/pages/admin/roles/edit(default).phtml b/pages/admin/roles/edit(default).phtml index bac0448..4061a1e 100644 --- a/pages/admin/roles/edit(default).phtml +++ b/pages/admin/roles/edit(default).phtml @@ -53,6 +53,15 @@ $isReadOnly = !$canUpdateRole; $validationSummaryErrors = $validationSummaryErrors ?? ($errors ?? []); require templatePath('partials/app-details-validation-summary.phtml'); ?> + +
+
    + +
  • + +
+
+ isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('description')) { +if ($request->isMethod('POST')) { $post = $request->bodyAll(); $ssoFields = [ 'microsoft_enabled', diff --git a/pages/admin/tenants/delete($id).php b/pages/admin/tenants/delete($id).php index 91f6dbd..5b2723c 100644 --- a/pages/admin/tenants/delete($id).php +++ b/pages/admin/tenants/delete($id).php @@ -1,5 +1,6 @@ findByUuid($uuid); if (!$tenant) { + if (Request::wantsJson()) { + http_response_code(404); + Router::json(['error' => 'not_found']); + return; + } Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found'); Router::redirect('admin/tenants'); return; @@ -30,13 +36,25 @@ $decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ 'target_tenant_id' => $tenantId, ]); if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } Router::redirect('error/forbidden'); return; } $result = app(\MintyPHP\Service\Tenant\TenantService::class)->deleteByUuid($uuid); if (!($result['ok'] ?? false)) { - if (($result['error'] ?? '') === 'tenant_has_departments') { + $error = $result['error'] ?? 'not_found'; + $status = $result['status'] ?? 500; + if (Request::wantsJson()) { + http_response_code($status); + Router::json(['error' => $error]); + return; + } + if ($error === 'tenant_has_departments') { Flash::error('Tenant can not be deleted while departments are assigned', 'admin/tenants', 'tenant_has_departments'); Router::redirect('admin/tenants'); return; @@ -51,5 +69,11 @@ if ($currentUserId > 0) { app(\MintyPHP\Service\Auth\AuthService::class)->loadTenantDataIntoSession($currentUserId); } +if (Request::wantsJson()) { + http_response_code(204); + Router::json([]); + return; +} + Flash::success('Tenant deleted', 'admin/tenants', 'tenant_deleted'); Router::redirect('admin/tenants'); diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index 3b66704..e7fba76 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -79,7 +79,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('description')) { +if ($request->isMethod('POST')) { $post = $request->bodyAll(); $submitDecision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, diff --git a/pages/admin/users/create().php b/pages/admin/users/create().php index a1eef65..498b0ef 100644 --- a/pages/admin/users/create().php +++ b/pages/admin/users/create().php @@ -105,7 +105,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('email')) { +if ($request->isMethod('POST')) { $post = $request->bodyAll(); $input = $post; $tenantIds = $canManageAssignments ? $userAssignmentService->normalizeIdInput($input['tenant_ids'] ?? []) : []; diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 2fab850..9719fc5 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -161,7 +161,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { return; } -if ($request->hasBody('email')) { +if ($request->isMethod('POST')) { $post = $request->bodyAll(); $submitDecision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, diff --git a/tests/Service/Access/RoleServiceTest.php b/tests/Service/Access/RoleServiceTest.php index a59a6ad..c50244b 100644 --- a/tests/Service/Access/RoleServiceTest.php +++ b/tests/Service/Access/RoleServiceTest.php @@ -66,6 +66,41 @@ class RoleServiceTest extends TestCase $this->assertTrue($result['ok']); $this->assertSame('uuid-42', $result['uuid']); $this->assertSame(42, $result['id']); + $this->assertEmpty($result['warnings']); + } + + public function testCreateFromAdminReturnsDuplicateCodeAsWarning(): void + { + $this->roleRepo + ->expects($this->any()) + ->method('existsByCode') + ->with('DUPE', 0) + ->willReturn(true); + + $this->roleRepo + ->expects($this->any()) + ->method('create') + ->willReturn(50); + + $this->roleRepo + ->expects($this->any()) + ->method('find') + ->with(50) + ->willReturn(['id' => 50, 'uuid' => 'uuid-50', 'description' => 'Dupe Role']); + + $this->auditService + ->expects($this->once()) + ->method('record'); + + $result = $this->service->createFromAdmin([ + 'description' => 'Dupe Role', + 'code' => 'DUPE', + 'active' => 1, + ], 1); + + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['warnings']); + $this->assertEmpty($result['errors'] ?? []); } public function testCreateFromAdminRejectsEmptyDescription(): void @@ -184,7 +219,7 @@ class RoleServiceTest extends TestCase $this->assertTrue($result['ok']); } - public function testUpdateFromAdminRejectsDuplicateCode(): void + public function testUpdateFromAdminReturnsDuplicateCodeAsWarning(): void { $this->roleRepo ->expects($this->once()) @@ -193,17 +228,30 @@ class RoleServiceTest extends TestCase ->willReturn(true); $this->roleRepo - ->expects($this->never()) - ->method('update'); + ->expects($this->once()) + ->method('find') + ->with(10) + ->willReturn(['id' => 10, 'uuid' => 'uuid-10', 'active' => 1]); + + $this->roleRepo + ->expects($this->once()) + ->method('update') + ->with(10, $this->anything()) + ->willReturn(true); + + $this->auditService + ->expects($this->once()) + ->method('record'); $result = $this->service->updateFromAdmin(10, [ 'description' => 'Manager', 'code' => 'ADMIN', 'active' => 1, - ]); + ], 5); - $this->assertFalse($result['ok']); - $this->assertNotEmpty($result['errors']); + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['warnings']); + $this->assertEmpty($result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorWhenRepositoryUpdateFails(): void diff --git a/tests/Service/Org/DepartmentServiceTest.php b/tests/Service/Org/DepartmentServiceTest.php index f3ad759..4b74625 100644 --- a/tests/Service/Org/DepartmentServiceTest.php +++ b/tests/Service/Org/DepartmentServiceTest.php @@ -207,6 +207,10 @@ class DepartmentServiceTest extends TestCase ->with('abc-123') ->willReturn($department); + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->any())->method('countUsersByDepartmentIds')->with([5])->willReturn([]); + $this->userServicesFactory->expects($this->any())->method('createUserDepartmentRepository')->willReturn($userDeptRepo); + $this->departmentRepository ->expects($this->once()) ->method('delete') @@ -263,6 +267,10 @@ class DepartmentServiceTest extends TestCase ->with('abc-999') ->willReturn(['id' => 9, 'uuid' => 'abc-999', 'description' => 'Ops']); + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->any())->method('countUsersByDepartmentIds')->with([9])->willReturn([]); + $this->userServicesFactory->expects($this->any())->method('createUserDepartmentRepository')->willReturn($userDeptRepo); + $this->departmentRepository ->expects($this->once()) ->method('delete') @@ -280,6 +288,72 @@ class DepartmentServiceTest extends TestCase $this->assertSame('delete_failed', $result['error']); } + public function testDeleteByUuidReturns409WhenUsersAssigned(): void + { + $this->departmentRepository + ->expects($this->once()) + ->method('findByUuid') + ->with('abc-dept') + ->willReturn(['id' => 5, 'uuid' => 'abc-dept', 'description' => 'Sales']); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->once()) + ->method('countUsersByDepartmentIds') + ->with([5]) + ->willReturn([5 => 3]); + $this->userServicesFactory->expects($this->once()) + ->method('createUserDepartmentRepository') + ->willReturn($userDeptRepo); + + $this->departmentRepository + ->expects($this->never()) + ->method('delete'); + + $this->systemAuditService + ->expects($this->never()) + ->method('record'); + + $result = $this->service->deleteByUuid('abc-dept'); + + $this->assertFalse($result['ok']); + $this->assertSame(409, $result['status']); + $this->assertSame('department_has_users', $result['error']); + $this->assertSame(3, $result['user_count']); + } + + public function testDeleteByUuidDeletesWhenNoUsersAssigned(): void + { + $this->departmentRepository + ->expects($this->once()) + ->method('findByUuid') + ->with('abc-empty') + ->willReturn(['id' => 7, 'uuid' => 'abc-empty', 'description' => 'Empty Dept']); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->once()) + ->method('countUsersByDepartmentIds') + ->with([7]) + ->willReturn([]); + $this->userServicesFactory->expects($this->once()) + ->method('createUserDepartmentRepository') + ->willReturn($userDeptRepo); + + $this->departmentRepository + ->expects($this->once()) + ->method('delete') + ->with(7) + ->willReturn(true); + + $this->systemAuditService + ->expects($this->once()) + ->method('record'); + + $result = $this->service->deleteByUuid('abc-empty'); + + $this->assertTrue($result['ok']); + $this->assertSame('abc-empty', $result['department']['uuid']); + } + public function testSyncTenantsUsesFirstId(): void { $this->departmentRepository