tenantService->list(); $roles = $this->roleService->listActive(); $departments = $this->departmentService->list(); $sortByDescription = static fn (array $left, array $right): int => strcmp((string) ($left['description'] ?? ''), (string) ($right['description'] ?? '')); usort($tenants, $sortByDescription); usort($roles, $sortByDescription); usort($departments, $sortByDescription); return [ 'tenants' => $tenants, 'roles' => $roles, 'departments' => $departments, 'values' => [ 'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(), 'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(), 'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(), 'app_title' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_TITLE_KEY), 'app_locale' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_LOCALE_KEY), 'app_theme' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_KEY), 'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(), 'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(), 'app_primary_color' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_PRIMARY_COLOR_KEY), '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(), 'api_cors_allowed_origins' => $this->settingsApiPolicyGateway->getApiCorsAllowedOriginsText(), '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(), 'microsoft_shared_client_id' => $this->settingsMetadataGateway->getValue(SettingKeys::MICROSOFT_SHARED_CLIENT_ID_KEY), 'microsoft_authority' => $this->settingsMicrosoftGateway->getMicrosoftAuthority(), 'smtp_host' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_HOST_KEY), 'smtp_port' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_PORT_KEY), 'smtp_user' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_USER_KEY), 'smtp_secure' => $this->settingsSmtpGateway->getSmtpSecure(), 'smtp_from' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_FROM_KEY), 'smtp_from_name' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_FROM_NAME_KEY), ], 'settings' => [ 'default_tenant' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_TENANT_KEY), 'default_role' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_ROLE_KEY), 'default_department' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_DEPARTMENT_KEY), 'app_title' => $this->settingsMetadataGateway->get(SettingKeys::APP_TITLE_KEY), 'app_locale' => $this->settingsMetadataGateway->get(SettingKeys::APP_LOCALE_KEY), 'app_theme' => $this->settingsMetadataGateway->get(SettingKeys::APP_THEME_KEY), 'app_theme_user' => $this->settingsMetadataGateway->get(SettingKeys::APP_THEME_USER_KEY), 'app_registration' => $this->settingsMetadataGateway->get(SettingKeys::APP_REGISTRATION_KEY), 'app_primary_color' => $this->settingsMetadataGateway->get(SettingKeys::APP_PRIMARY_COLOR_KEY), 'api_token_default_ttl_days' => $this->settingsMetadataGateway->get(SettingKeys::API_TOKEN_DEFAULT_TTL_DAYS_KEY), 'api_token_max_ttl_days' => $this->settingsMetadataGateway->get(SettingKeys::API_TOKEN_MAX_TTL_DAYS_KEY), 'user_inactivity_deactivate_days' => $this->settingsMetadataGateway->get(SettingKeys::USER_INACTIVITY_DEACTIVATE_DAYS_KEY), 'user_inactivity_delete_days' => $this->settingsMetadataGateway->get(SettingKeys::USER_INACTIVITY_DELETE_DAYS_KEY), 'session_idle_timeout_minutes' => $this->settingsMetadataGateway->get(SettingKeys::SESSION_IDLE_TIMEOUT_MINUTES_KEY), 'session_absolute_timeout_hours' => $this->settingsMetadataGateway->get(SettingKeys::SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY), 'microsoft_auto_remember_default' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_AUTO_REMEMBER_DEFAULT_KEY), 'remember_token_lifetime_days' => $this->settingsMetadataGateway->get(SettingKeys::REMEMBER_TOKEN_LIFETIME_DAYS_KEY), 'api_cors_allowed_origins' => $this->settingsMetadataGateway->get(SettingKeys::API_CORS_ALLOWED_ORIGINS_KEY), 'system_audit_enabled' => $this->settingsMetadataGateway->get(SettingKeys::SYSTEM_AUDIT_ENABLED_KEY), 'system_audit_retention_days' => $this->settingsMetadataGateway->get(SettingKeys::SYSTEM_AUDIT_RETENTION_DAYS_KEY), 'frontend_telemetry_enabled' => $this->settingsMetadataGateway->get(SettingKeys::FRONTEND_TELEMETRY_ENABLED_KEY), 'frontend_telemetry_sample_rate' => $this->settingsMetadataGateway->get(SettingKeys::FRONTEND_TELEMETRY_SAMPLE_RATE_KEY), 'frontend_telemetry_allowed_events' => $this->settingsMetadataGateway->get(SettingKeys::FRONTEND_TELEMETRY_ALLOWED_EVENTS_KEY), 'microsoft_shared_client_id' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_SHARED_CLIENT_ID_KEY), 'microsoft_shared_client_secret_enc' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY), 'microsoft_authority' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_AUTHORITY_KEY), 'smtp_host' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_HOST_KEY), 'smtp_port' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_PORT_KEY), 'smtp_user' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_USER_KEY), 'smtp_password' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_PASSWORD_KEY), 'smtp_secure' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_SECURE_KEY), 'smtp_from' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_FROM_KEY), 'smtp_from_name' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_FROM_NAME_KEY), ], 'active_login_tokens' => $this->rememberTokenRepository->countActive(), 'active_api_tokens' => $this->apiTokenRepository->countActive(), ]; } public function updateFromAdmin(array $post): array { $input = $this->sanitizeAdminInput($post); $beforeAudit = $this->captureAuditSnapshot(); $errors = []; $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(), '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(), ]; } 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']); } /** @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']; } 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']; } if (!$this->settingsApiPolicyGateway->setApiCorsAllowedOrigins($input['api_cors_allowed_origins'])) { $errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_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 ($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); } 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']; } 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($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']; } return $errors; } /** @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']; } 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']; } if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetryEnabled($input['frontend_telemetry_enabled'])) { $errors[] = ['message' => 'Frontend telemetry enabled setting is invalid', 'key' => 'frontend_telemetry_enabled_invalid']; } $rateValue = null; if ($input['frontend_telemetry_sample_rate'] !== '') { $rateValue = is_numeric($input['frontend_telemetry_sample_rate']) ? (float) $input['frontend_telemetry_sample_rate'] : NAN; } if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetrySampleRate($rateValue)) { $errors[] = ['message' => 'Frontend telemetry sample rate is invalid', 'key' => 'frontend_telemetry_sample_rate_invalid']; } if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetryAllowedEvents($input['frontend_telemetry_allowed_events'])) { $errors[] = ['message' => 'Frontend telemetry allowed events are invalid', 'key' => 'frontend_telemetry_allowed_events_invalid']; } return $errors; } /** @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']]; } return []; } 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']; } if (!$this->settingsMicrosoftGateway->setMicrosoftAuthority($input['microsoft_authority'])) { $errors[] = ['message' => 'Microsoft authority is invalid', 'key' => 'microsoft_authority_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' => $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), 'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled() ? '1' : '0', 'frontend_telemetry_sample_rate' => (string) $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(), 'frontend_telemetry_allowed_events' => implode(',', $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents()), ]); } /** @param list $errors */ private function recordAudit(array $errors, array $beforeAudit, array $afterAudit): void { $outcome = $errors === [] ? 'success' : 'failed'; $errorKeys = []; foreach ($errors as $error) { $key = trim((string) $error['key']); if ($key !== '') { $errorKeys[] = $key; } } $this->systemAuditService->record('admin.settings.update', $outcome, [ 'before' => $beforeAudit, 'after' => $afterAudit, 'error_code' => $errors === [] ? null : 'validation_error', 'metadata' => $errors === [] ? [] : ['error_keys' => $errorKeys], ]); } private function normalizeScalarText(mixed $value): string { if (is_array($value)) { return ''; } return trim((string) $value); } private function normalizePrimaryColor(mixed $value): string { $color = $this->normalizeScalarText($value); if (in_array(strtolower($color), ['null', 'undefined'], true)) { return ''; } return $color; } }