diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index bd13d1f..4a91e1e 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -326,5 +326,15 @@ "Search customers...": "Kunden suchen...", "more than resolved": "mehr als gelöst", "SLA breaches": "SLA-Verletzungen", - "Oldest ticket": "Ältestes Ticket" + "Oldest ticket": "Ältestes Ticket", + "Tenant override": "Mandanten-Override", + "Configuration source": "Konfigurationsquelle", + "Current tenant": "Aktueller Mandant", + "Use tenant-specific configuration": "Mandantenspezifische Konfiguration verwenden", + "When enabled, this tenant uses its own BC connection instead of the global configuration.": "Wenn aktiviert, nutzt dieser Mandant seine eigene BC-Verbindung anstelle der globalen Konfiguration.", + "Tenant connection": "Mandanten-Verbindung", + "Tenant authentication": "Mandanten-Authentifizierung", + "This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.": "Dieser Mandant erbt die globale Konfiguration. Aktivieren Sie den Schalter oben, um mandantenspezifische Einstellungen zu verwenden.", + "Tenant settings saved": "Mandanten-Einstellungen gespeichert", + "Token endpoint must use HTTPS": "Token-Endpunkt muss HTTPS verwenden" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index ecc2b36..e8e71dc 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -326,5 +326,15 @@ "Search customers...": "Search customers...", "more than resolved": "more than resolved", "SLA breaches": "SLA breaches", - "Oldest ticket": "Oldest ticket" + "Oldest ticket": "Oldest ticket", + "Tenant override": "Tenant override", + "Configuration source": "Configuration source", + "Current tenant": "Current tenant", + "Use tenant-specific configuration": "Use tenant-specific configuration", + "When enabled, this tenant uses its own BC connection instead of the global configuration.": "When enabled, this tenant uses its own BC connection instead of the global configuration.", + "Tenant connection": "Tenant connection", + "Tenant authentication": "Tenant authentication", + "This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.": "This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.", + "Tenant settings saved": "Tenant settings saved", + "Token endpoint must use HTTPS": "Token endpoint must use HTTPS" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index 24f5bbe..3c11312 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -9,8 +9,11 @@ use MintyPHP\Module\Helpdesk\Service\BcODataGateway; use MintyPHP\Module\Helpdesk\Service\BcSoapGateway; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; +use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; +use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway; +use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsRepository; use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository; use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine; use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; @@ -31,14 +34,27 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(SettingServicesFactory::class)->createSettingsCryptoGateway() )); - $container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway( + $container->set(HelpdeskTenantSettingsRepository::class, static fn (): HelpdeskTenantSettingsRepository => new HelpdeskTenantSettingsRepository()); + + $container->set(HelpdeskTenantSettingsGateway::class, static fn (AppContainer $c): HelpdeskTenantSettingsGateway => new HelpdeskTenantSettingsGateway( + $c->get(HelpdeskTenantSettingsRepository::class), + $c->get(SettingServicesFactory::class)->createSettingsCryptoGateway() + )); + + $container->set(EffectiveHelpdeskSettingsService::class, static fn (AppContainer $c): EffectiveHelpdeskSettingsService => new EffectiveHelpdeskSettingsService( $c->get(HelpdeskSettingsGateway::class), + $c->get(HelpdeskTenantSettingsGateway::class), + $c->get(SessionStoreInterface::class) + )); + + $container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway( + $c->get(EffectiveHelpdeskSettingsService::class), $c->get(HelpdeskOAuthTokenService::class), $c->get(SessionStoreInterface::class) )); $container->set(BcSoapGateway::class, static fn (AppContainer $c): BcSoapGateway => new BcSoapGateway( - $c->get(HelpdeskSettingsGateway::class), + $c->get(EffectiveHelpdeskSettingsService::class), $c->get(HelpdeskOAuthTokenService::class), $c->get(SessionStoreInterface::class) )); @@ -48,18 +64,18 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar )); $container->set(HelpdeskOAuthTokenService::class, static fn (AppContainer $c): HelpdeskOAuthTokenService => new HelpdeskOAuthTokenService( - $c->get(HelpdeskSettingsGateway::class), + $c->get(EffectiveHelpdeskSettingsService::class), $c->get(HelpdeskTokenRepository::class) )); $container->set(DebitorSearchService::class, static fn (AppContainer $c): DebitorSearchService => new DebitorSearchService( $c->get(BcODataGateway::class), - $c->get(HelpdeskSettingsGateway::class) + $c->get(EffectiveHelpdeskSettingsService::class) )); $container->set(DebitorDetailService::class, static fn (AppContainer $c): DebitorDetailService => new DebitorDetailService( $c->get(BcODataGateway::class), - $c->get(HelpdeskSettingsGateway::class) + $c->get(EffectiveHelpdeskSettingsService::class) )); $container->set(TicketCommunicationService::class, static fn (AppContainer $c): TicketCommunicationService => new TicketCommunicationService( diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index 02c2ddf..fb990ea 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -26,7 +26,7 @@ class BcODataGateway private const REQUEST_TIMEOUT = 30; public function __construct( - private readonly HelpdeskSettingsGateway $settingsGateway, + private readonly EffectiveHelpdeskSettingsService $settingsGateway, private readonly HelpdeskOAuthTokenService $oauthTokenService, private readonly SessionStoreInterface $sessionStore ) { diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php index 470f352..271ff7c 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php @@ -16,7 +16,7 @@ class BcSoapGateway private const SOAP_ACTION_GET_TICKET_FILE = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketFile'; public function __construct( - private readonly HelpdeskSettingsGateway $settingsGateway, + private readonly EffectiveHelpdeskSettingsService $settingsGateway, private readonly HelpdeskOAuthTokenService $oauthTokenService, private readonly SessionStoreInterface $sessionStore ) { diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index d347392..7fd7f05 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -15,7 +15,7 @@ class DebitorDetailService public function __construct( private readonly BcODataGateway $bcODataGateway, - private readonly HelpdeskSettingsGateway $settingsGateway + private readonly EffectiveHelpdeskSettingsService $settingsGateway ) { } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php index 00ce78a..26513b5 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php @@ -11,7 +11,7 @@ class DebitorSearchService public function __construct( private readonly BcODataGateway $bcODataGateway, - private readonly HelpdeskSettingsGateway $settingsGateway + private readonly EffectiveHelpdeskSettingsService $settingsGateway ) { } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/EffectiveHelpdeskSettings.php b/modules/helpdesk/lib/Module/Helpdesk/Service/EffectiveHelpdeskSettings.php new file mode 100644 index 0000000..56a7f62 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/EffectiveHelpdeskSettings.php @@ -0,0 +1,71 @@ +companyName); + + return $this->odataBaseUrl . "/Company('" . $company . "')/" . $entitySet; + } + + /** + * @return array List of missing setting descriptions (empty = valid) + */ + public function validateConfiguration(): array + { + $missing = []; + + if ($this->basicUser === null && $this->authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) { + $missing[] = 'BC Basic Auth User'; + } + if ($this->basicPassword === null && $this->authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) { + $missing[] = 'BC Basic Auth Password'; + } + if ($this->authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) { + if ($this->oauthClientId === null) { + $missing[] = 'BC OAuth2 Client ID'; + } + if ($this->oauthClientSecret === null) { + $missing[] = 'BC OAuth2 Client Secret'; + } + if ($this->oauthTokenEndpoint === null) { + $missing[] = 'BC OAuth2 Token Endpoint'; + } + } + + return $missing; + } + + public function isConfigured(): bool + { + return $this->validateConfiguration() === []; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/EffectiveHelpdeskSettingsService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/EffectiveHelpdeskSettingsService.php new file mode 100644 index 0000000..03ab45a --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/EffectiveHelpdeskSettingsService.php @@ -0,0 +1,199 @@ + 0 && $this->tenantGateway->isOverrideEnabled($tenantId)) { + return $this->buildFromTenant($tenantId); + } + + return $this->buildFromGlobal(); + } + + /** + * Resolve for the current session tenant. + */ + public function resolveForCurrentTenant(): EffectiveHelpdeskSettings + { + $tenantId = $this->resolveCurrentTenantId(); + + return $this->resolveForTenant($tenantId); + } + + // --- Proxy methods for gateway compatibility --- + // These allow consumers to swap HelpdeskSettingsGateway → EffectiveHelpdeskSettingsService + // with minimal code changes. + + public function getAuthMode(): string + { + return $this->resolveForCurrentTenant()->authMode; + } + + public function getODataBaseUrl(): string + { + return $this->resolveForCurrentTenant()->odataBaseUrl; + } + + public function getCompanyName(): string + { + return $this->resolveForCurrentTenant()->companyName; + } + + public function getBasicUser(): ?string + { + return $this->resolveForCurrentTenant()->basicUser; + } + + public function getBasicPassword(): ?string + { + return $this->resolveForCurrentTenant()->basicPassword; + } + + public function getOAuthTenantId(): ?string + { + return $this->resolveForCurrentTenant()->oauthTenantId; + } + + public function getOAuthClientId(): ?string + { + return $this->resolveForCurrentTenant()->oauthClientId; + } + + public function getOAuthClientSecret(): ?string + { + return $this->resolveForCurrentTenant()->oauthClientSecret; + } + + public function getOAuthTokenEndpoint(): ?string + { + return $this->resolveForCurrentTenant()->oauthTokenEndpoint; + } + + public function buildEntityUrl(string $entitySet): string + { + return $this->resolveForCurrentTenant()->buildEntityUrl($entitySet); + } + + /** + * @return array + */ + public function validateConfiguration(): array + { + return $this->resolveForCurrentTenant()->validateConfiguration(); + } + + public function isConfigured(): bool + { + return $this->resolveForCurrentTenant()->isConfigured(); + } + + /** + * @return array{ + * config: array, + * source: 'default'|'settings'|'fallback_invalid_json' + * } + */ + public function getSystemRecommendationsConfigEnvelope(): array + { + return $this->globalGateway->getSystemRecommendationsConfigEnvelope(); + } + + /** + * @return array + */ + public function getSystemRecommendationsConfig(): array + { + return $this->globalGateway->getSystemRecommendationsConfig(); + } + + /** + * @return array{ + * config: array, + * source: 'default'|'settings'|'fallback_invalid_json' + * } + */ + public function getControllingRiskConfigEnvelope(): array + { + return $this->globalGateway->getControllingRiskConfigEnvelope(); + } + + /** + * @return array + */ + public function getControllingRiskConfig(): array + { + return $this->globalGateway->getControllingRiskConfig(); + } + + private function buildFromGlobal(): EffectiveHelpdeskSettings + { + return new EffectiveHelpdeskSettings( + source: 'global', + authMode: $this->globalGateway->getAuthMode(), + odataBaseUrl: $this->globalGateway->getODataBaseUrl(), + companyName: $this->globalGateway->getCompanyName(), + basicUser: $this->globalGateway->getBasicUser(), + basicPassword: $this->globalGateway->getBasicPassword(), + oauthTenantId: $this->globalGateway->getOAuthTenantId(), + oauthClientId: $this->globalGateway->getOAuthClientId(), + oauthClientSecret: $this->globalGateway->getOAuthClientSecret(), + oauthTokenEndpoint: $this->globalGateway->getOAuthTokenEndpoint(), + systemRecommendationsConfigRaw: null, + controllingRiskConfigRaw: null, + ); + } + + private function buildFromTenant(int $tenantId): EffectiveHelpdeskSettings + { + return new EffectiveHelpdeskSettings( + source: 'tenant', + authMode: $this->tenantGateway->getAuthMode($tenantId), + odataBaseUrl: $this->tenantGateway->getODataBaseUrl($tenantId), + companyName: $this->tenantGateway->getCompanyName($tenantId), + basicUser: $this->tenantGateway->getBasicUser($tenantId), + basicPassword: $this->tenantGateway->getBasicPassword($tenantId), + oauthTenantId: $this->tenantGateway->getOAuthTenantId($tenantId), + oauthClientId: $this->tenantGateway->getOAuthClientId($tenantId), + oauthClientSecret: $this->tenantGateway->getOAuthClientSecret($tenantId), + oauthTokenEndpoint: $this->tenantGateway->getOAuthTokenEndpoint($tenantId), + systemRecommendationsConfigRaw: $this->tenantGateway->getSystemRecommendationsConfig($tenantId), + controllingRiskConfigRaw: $this->tenantGateway->getControllingRiskConfig($tenantId), + ); + } + + private function resolveCurrentTenantId(): int + { + $session = $this->sessionStore->all(); + $tenant = $session['current_tenant'] ?? null; + if (!is_array($tenant)) { + return 0; + } + + return (int) ($tenant['id'] ?? 0); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php index 52332d2..419ac15 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php @@ -15,7 +15,7 @@ class HelpdeskOAuthTokenService private const REQUEST_TIMEOUT = 30; public function __construct( - private readonly HelpdeskSettingsGateway $settingsGateway, + private readonly EffectiveHelpdeskSettingsService $settingsGateway, private readonly HelpdeskTokenRepository $tokenRepository ) { } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php new file mode 100644 index 0000000..7c83d65 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php @@ -0,0 +1,203 @@ +repository->findByTenantId($tenantId); + if ($row === null) { + return false; + } + + return (int) ($row['override_enabled'] ?? 0) === 1; + } + + /** + * @return array|null Raw row data (secrets still encrypted) + */ + public function findByTenantId(int $tenantId): ?array + { + return $this->repository->findByTenantId($tenantId); + } + + public function getAuthMode(int $tenantId): string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['auth_mode'] ?? '')); + + return $value === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2 + ? HelpdeskSettingsGateway::AUTH_MODE_OAUTH2 + : HelpdeskSettingsGateway::AUTH_MODE_BASIC; + } + + public function getODataBaseUrl(int $tenantId): string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['odata_base_url'] ?? '')); + + return $value !== '' ? rtrim($value, '/') : HelpdeskSettingsGateway::DEFAULT_ODATA_BASE_URL; + } + + public function getCompanyName(int $tenantId): string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['company_name'] ?? '')); + + return $value !== '' ? $value : HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME; + } + + public function getBasicUser(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['basic_user'] ?? '')); + + return $value !== '' ? $value : null; + } + + public function getBasicPassword(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + $encrypted = trim((string) ($row['basic_password_enc'] ?? '')); + if ($encrypted === '') { + return null; + } + + try { + $decrypted = $this->settingsCryptoGateway->decryptString($encrypted); + } catch (\Throwable) { + return null; + } + + $decrypted = trim($decrypted); + + return $decrypted !== '' ? $decrypted : null; + } + + public function getOAuthTenantId(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['oauth_tenant_id'] ?? '')); + + return $value !== '' ? $value : null; + } + + public function getOAuthClientId(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['oauth_client_id'] ?? '')); + + return $value !== '' ? $value : null; + } + + public function getOAuthClientSecret(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + $encrypted = trim((string) ($row['oauth_client_secret_enc'] ?? '')); + if ($encrypted === '') { + return null; + } + + try { + $decrypted = $this->settingsCryptoGateway->decryptString($encrypted); + } catch (\Throwable) { + return null; + } + + $decrypted = trim($decrypted); + + return $decrypted !== '' ? $decrypted : null; + } + + public function getOAuthTokenEndpoint(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + $value = trim((string) ($row['oauth_token_endpoint'] ?? '')); + + return $value !== '' ? $value : null; + } + + public function getSystemRecommendationsConfig(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + + return ($row['system_recommendations_config'] ?? null) !== null + ? (string) $row['system_recommendations_config'] + : null; + } + + public function getControllingRiskConfig(int $tenantId): ?string + { + $row = $this->repository->findByTenantId($tenantId); + + return ($row['controlling_risk_config'] ?? null) !== null + ? (string) $row['controlling_risk_config'] + : null; + } + + /** + * Persist tenant override settings. Secrets are encrypted before storage. + * + * @param array $data Field values (plain-text passwords/secrets accepted) + */ + public function save(int $tenantId, array $data): bool + { + if ($tenantId <= 0) { + return false; + } + + // Encrypt secrets before storage + if (isset($data['basic_password']) && is_string($data['basic_password'])) { + $password = trim($data['basic_password']); + unset($data['basic_password']); + if ($password !== '') { + try { + $data['basic_password_enc'] = $this->settingsCryptoGateway->encryptString($password); + } catch (\Throwable) { + return false; + } + } else { + $data['basic_password_enc'] = null; + } + } + + if (isset($data['oauth_client_secret']) && is_string($data['oauth_client_secret'])) { + $secret = trim($data['oauth_client_secret']); + unset($data['oauth_client_secret']); + if ($secret !== '') { + try { + $data['oauth_client_secret_enc'] = $this->settingsCryptoGateway->encryptString($secret); + } catch (\Throwable) { + return false; + } + } else { + $data['oauth_client_secret_enc'] = null; + } + } + + return $this->repository->upsert($tenantId, $data); + } + + public function deleteByTenantId(int $tenantId): bool + { + return $this->repository->deleteByTenantId($tenantId); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php new file mode 100644 index 0000000..81a4d27 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php @@ -0,0 +1,146 @@ +|null + */ + public function findByTenantId(int $tenantId): ?array + { + if ($tenantId <= 0) { + return null; + } + + $result = DB::selectOne( + 'SELECT * FROM helpdesk_tenant_settings WHERE tenant_id = ? LIMIT 1', + (string) $tenantId + ); + + if (!is_array($result) || $result === []) { + return null; + } + + // MintyPHP wraps selectOne results in ['table_name' => [...]] + return $result['helpdesk_tenant_settings'] ?? null; + } + + /** + * @param array $data + */ + public function upsert(int $tenantId, array $data): bool + { + if ($tenantId <= 0) { + return false; + } + + $existing = $this->findByTenantId($tenantId); + + if ($existing !== null) { + return $this->update($tenantId, $data); + } + + return $this->insert($tenantId, $data); + } + + public function deleteByTenantId(int $tenantId): bool + { + if ($tenantId <= 0) { + return false; + } + + return (bool) DB::delete( + 'DELETE FROM helpdesk_tenant_settings WHERE tenant_id = ?', + (string) $tenantId + ); + } + + /** + * @param array $data + */ + private function insert(int $tenantId, array $data): bool + { + $row = $this->buildRow($data); + + return (bool) DB::insert( + 'INSERT INTO helpdesk_tenant_settings (tenant_id, override_enabled, auth_mode, odata_base_url, company_name, basic_user, basic_password_enc, oauth_tenant_id, oauth_client_id, oauth_client_secret_enc, oauth_token_endpoint, system_recommendations_config, controlling_risk_config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + (string) $tenantId, + $row['override_enabled'], + $row['auth_mode'], + $row['odata_base_url'], + $row['company_name'], + $row['basic_user'], + $row['basic_password_enc'], + $row['oauth_tenant_id'], + $row['oauth_client_id'], + $row['oauth_client_secret_enc'], + $row['oauth_token_endpoint'], + $row['system_recommendations_config'], + $row['controlling_risk_config'] + ); + } + + /** + * @param array $data + */ + private function update(int $tenantId, array $data): bool + { + $row = $this->buildRow($data); + + return (bool) DB::update( + 'UPDATE helpdesk_tenant_settings SET override_enabled = ?, auth_mode = ?, odata_base_url = ?, company_name = ?, basic_user = ?, basic_password_enc = ?, oauth_tenant_id = ?, oauth_client_id = ?, oauth_client_secret_enc = ?, oauth_token_endpoint = ?, system_recommendations_config = ?, controlling_risk_config = ? WHERE tenant_id = ?', + $row['override_enabled'], + $row['auth_mode'], + $row['odata_base_url'], + $row['company_name'], + $row['basic_user'], + $row['basic_password_enc'], + $row['oauth_tenant_id'], + $row['oauth_client_id'], + $row['oauth_client_secret_enc'], + $row['oauth_token_endpoint'], + $row['system_recommendations_config'], + $row['controlling_risk_config'], + (string) $tenantId + ); + } + + /** + * Build a complete row array from partial data, using empty strings as defaults. + * + * @param array $data + * @return array + */ + private function buildRow(array $data): array + { + $columns = [ + 'override_enabled', + 'auth_mode', + 'odata_base_url', + 'company_name', + 'basic_user', + 'basic_password_enc', + 'oauth_tenant_id', + 'oauth_client_id', + 'oauth_client_secret_enc', + 'oauth_token_endpoint', + 'system_recommendations_config', + 'controlling_risk_config', + ]; + + $row = []; + foreach ($columns as $col) { + $row[$col] = (string) ($data[$col] ?? ''); + } + + return $row; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php index 7340456..7c90a7a 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php @@ -48,6 +48,14 @@ class HelpdeskTokenRepository } } + /** + * Delete all cached tokens (used when BC config changes to prevent stale credentials). + */ + public function deleteAll(): bool + { + return (bool) DB::delete('DELETE FROM helpdesk_oauth_token_cache WHERE 1=1'); + } + /** * Store a new token in the cache (encrypted, tenant-scoped). */ diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php index 9e48203..dd135d6 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php @@ -142,6 +142,9 @@ final class RiskRadarService $dimensions = self::scoreDimensions($c); $totalScore = self::computeTotalScore($dimensions); + if ($totalScore === 0) { + continue; + } $level = $totalScore >= self::LEVEL_HIGH ? 'high' : ($totalScore >= self::LEVEL_MEDIUM ? 'medium' : 'low'); diff --git a/modules/helpdesk/migrations/002_create_tenant_settings.sql b/modules/helpdesk/migrations/002_create_tenant_settings.sql new file mode 100644 index 0000000..e039507 --- /dev/null +++ b/modules/helpdesk/migrations/002_create_tenant_settings.sql @@ -0,0 +1,21 @@ +-- Helpdesk module: per-tenant BC connection settings override +CREATE TABLE IF NOT EXISTS `helpdesk_tenant_settings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `override_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `auth_mode` VARCHAR(20) DEFAULT NULL, + `odata_base_url` VARCHAR(500) DEFAULT NULL, + `company_name` VARCHAR(255) DEFAULT NULL, + `basic_user` VARCHAR(255) DEFAULT NULL, + `basic_password_enc` TEXT DEFAULT NULL, + `oauth_tenant_id` VARCHAR(255) DEFAULT NULL, + `oauth_client_id` VARCHAR(255) DEFAULT NULL, + `oauth_client_secret_enc` TEXT DEFAULT NULL, + `oauth_token_endpoint` VARCHAR(500) DEFAULT NULL, + `system_recommendations_config` TEXT DEFAULT NULL, + `controlling_risk_config` TEXT DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `idx_helpdesk_tenant_settings_tenant` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml index 49fc9f1..1b6215d 100644 --- a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml @@ -31,7 +31,6 @@ $periodLabels = [ data-label-open="" data-label-overdue="" data-label-net="" - data-label-search="" data-label-critical="" data-label-ticket="" data-label-status="" @@ -61,7 +60,6 @@ $periodLabels = [
-
checked> @@ -71,17 +69,9 @@ $periodLabels = [
-
- -
-
-
-
- -
-
+
@@ -99,25 +89,6 @@ $periodLabels = [
diff --git a/modules/helpdesk/pages/helpdesk/settings/index().php b/modules/helpdesk/pages/helpdesk/settings/index().php index 617b4d7..05d318e 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index().php +++ b/modules/helpdesk/pages/helpdesk/settings/index().php @@ -1,8 +1,11 @@ all(); +$currentTenantId = (int) ($session['current_tenant']['id'] ?? 0); +$currentTenantName = trim((string) ($session['current_tenant']['description'] ?? '')); if ($request->isMethod('POST') && !Session::checkCsrfToken()) { Flash::error(t('Form expired, please try again'), 'helpdesk/settings', 'csrf_expired'); @@ -23,6 +33,69 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) { if ($request->isMethod('POST')) { $post = $request->bodyAll(); + $target = trim((string) ($post['save_target'] ?? 'global')); + + if ($target === 'tenant' && $currentTenantId > 0) { + // --- Tenant override save --- + $overrideEnabled = isset($post['override_enabled']); + + $tenantData = [ + 'override_enabled' => $overrideEnabled ? '1' : '0', + 'auth_mode' => trim((string) ($post['tenant_auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC)), + 'odata_base_url' => trim((string) ($post['tenant_odata_base_url'] ?? '')), + 'company_name' => trim((string) ($post['tenant_company_name'] ?? '')), + 'basic_user' => trim((string) ($post['tenant_basic_user'] ?? '')), + 'oauth_tenant_id' => trim((string) ($post['tenant_oauth_tenant_id'] ?? '')), + 'oauth_client_id' => trim((string) ($post['tenant_oauth_client_id'] ?? '')), + 'oauth_token_endpoint' => trim((string) ($post['tenant_oauth_token_endpoint'] ?? '')), + ]; + + $errorBag = formErrors(); + + $tenantOdataUrl = $tenantData['odata_base_url']; + if ($tenantOdataUrl !== '' && !preg_match('#^https://#i', $tenantOdataUrl)) { + $errorBag->add('tenant_odata_base_url', t('OData Base URL must use HTTPS')); + } + + $tenantTokenEndpoint = $tenantData['oauth_token_endpoint']; + if ($tenantTokenEndpoint !== '' && !preg_match('#^https://#i', $tenantTokenEndpoint)) { + $errorBag->add('tenant_oauth_token_endpoint', t('Token endpoint must use HTTPS')); + } + + if (!$errorBag->hasAny()) { + // Handle secrets — only update if new value provided + $tenantBasicPassword = trim((string) ($post['tenant_basic_password'] ?? '')); + if ($tenantBasicPassword !== '' && $tenantBasicPassword !== '********') { + $tenantData['basic_password'] = $tenantBasicPassword; + } + + $tenantOauthSecret = trim((string) ($post['tenant_oauth_client_secret'] ?? '')); + if ($tenantOauthSecret !== '' && $tenantOauthSecret !== '********') { + $tenantData['oauth_client_secret'] = $tenantOauthSecret; + } + + try { + $saved = $tenantSettingsGateway->save($currentTenantId, $tenantData); + } catch (\Throwable) { + $saved = false; + } + if ($saved) { + $tokenRepository->deleteAll(); + Flash::success(t('Tenant settings saved'), 'helpdesk/settings', 'tenant_settings_saved'); + } else { + Flash::error(t('Failed to save tenant settings'), 'helpdesk/settings', 'tenant_settings_error'); + } + } else { + flashFormErrors($errorBag, 'helpdesk/settings', 'tenant_settings_error'); + } + + Router::redirect('helpdesk/settings'); + + return; + } + + // --- Global save (existing flow) --- + $post = $request->bodyAll(); $authMode = trim((string) ($post['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC)); $odataBaseUrl = trim((string) ($post['odata_base_url'] ?? '')); @@ -121,6 +194,8 @@ if ($request->isMethod('POST')) { ], ]); + $tokenRepository->deleteAll(); + $action = trim((string) ($post['action'] ?? 'save')); Flash::success(t('Settings saved'), 'helpdesk/settings', 'settings_saved'); @@ -138,7 +213,7 @@ if ($request->isMethod('POST')) { return; } -// Load current values for display +// Load current global values for display $authMode = $settingsGateway->getAuthMode(); $odataBaseUrl = $settingsGateway->getODataBaseUrl(); $companyName = $settingsGateway->getCompanyName(); @@ -161,6 +236,18 @@ $controllingConfig = is_array($controllingConfigEnvelope['config'] ?? null) : []; $controllingConfigSource = (string) ($controllingConfigEnvelope['source'] ?? 'default'); +// Load tenant override values +$tenantOverrideEnabled = false; +$tenantRow = null; +$tenantHasBasicPassword = false; +$tenantHasOAuthSecret = false; +if ($currentTenantId > 0) { + $tenantOverrideEnabled = $tenantSettingsGateway->isOverrideEnabled($currentTenantId); + $tenantRow = $tenantSettingsGateway->findByTenantId($currentTenantId); + $tenantHasBasicPassword = $tenantSettingsGateway->getBasicPassword($currentTenantId) !== null; + $tenantHasOAuthSecret = $tenantSettingsGateway->getOAuthClientSecret($currentTenantId) !== null; +} + Buffer::set('title', t('Helpdesk settings')); Buffer::set('style_groups', json_encode(['helpdesk'])); $breadcrumbs = [ diff --git a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml index 3670ecc..1bf217f 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml @@ -15,6 +15,12 @@ * @var string $recommendationsConfigSource * @var array $controllingConfig * @var string $controllingConfigSource + * @var int $currentTenantId + * @var string $currentTenantName + * @var bool $tenantOverrideEnabled + * @var array|null $tenantRow + * @var bool $tenantHasBasicPassword + * @var bool $tenantHasOAuthSecret */ use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; @@ -48,6 +54,14 @@ $ctrlAvgResolution = is_array($controllingRules['avg_resolution_high'] ?? null) $ctrlOpenAging = is_array($controllingRules['open_aging'] ?? null) ? $controllingRules['open_aging'] : []; $ctrlUnassigned = is_array($controllingRules['unassigned_ratio'] ?? null) ? $controllingRules['unassigned_ratio'] : []; $isOAuth2 = $authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2; +$currentTenantId = (int) ($currentTenantId ?? 0); +$currentTenantName = (string) ($currentTenantName ?? ''); +$tenantOverrideEnabled = (bool) ($tenantOverrideEnabled ?? false); +$tenantRow = is_array($tenantRow ?? null) ? $tenantRow : []; +$tenantHasBasicPassword = (bool) ($tenantHasBasicPassword ?? false); +$tenantHasOAuthSecret = (bool) ($tenantHasOAuthSecret ?? false); +$tenantAuthMode = trim((string) ($tenantRow['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC)); +$tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2; ?>
@@ -109,12 +123,16 @@ $isOAuth2 = $authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2; data-test-default-label="" > +
+ 0): ?> + +
@@ -408,6 +426,120 @@ $isOAuth2 = $authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
+ + 0): ?> +
+
+ + + +
+ +
+
+ +
hidden> +
+ + + +
+ + + +
+
+ +
+ + + +
+ + +
hidden> + + +
+ +
hidden> + + + + +
+
+
+
+ + +
+

+
+ +
+
diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php index aa0f1b8..f94c3fc 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php @@ -5,14 +5,14 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\Service\BcODataGateway; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; -use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; +use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use PHPUnit\Framework\TestCase; class BcODataGatewayTest extends TestCase { private function createGatewayWithSettings(bool $isConfigured = true, ?SessionStoreInterface $sessionStore = null): BcODataGateway { - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('isConfigured')->willReturn($isConfigured); $settings->method('getODataBaseUrl')->willReturn('https://bc.example.com/ODataV4'); $settings->method('getCompanyName')->willReturn('Test Company'); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php index d7a82c8..e963b1c 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php @@ -5,6 +5,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\Service\BcSoapGateway; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; +use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; use PHPUnit\Framework\TestCase; @@ -15,7 +16,7 @@ class BcSoapGatewayTest extends TestCase */ private function createGateway(array $response): BcSoapGateway { - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('isConfigured')->willReturn(true); $settings->method('getODataBaseUrl')->willReturn('https://bc.example.com:7048/BusinessCentral-NUP/ODataV4'); $settings->method('getCompanyName')->willReturn('Test Company'); @@ -34,7 +35,7 @@ class BcSoapGatewayTest extends TestCase * @param array{http_code:int,body:string,error:string} $fakeResponse */ public function __construct( - HelpdeskSettingsGateway $settings, + EffectiveHelpdeskSettingsService $settings, HelpdeskOAuthTokenService $oauthTokenService, SessionStoreInterface $sessionStore, private readonly array $fakeResponse diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php index 4d4319a..b18930b 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php @@ -4,7 +4,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service; use MintyPHP\Module\Helpdesk\Service\BcODataGateway; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; -use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; +use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use PHPUnit\Framework\TestCase; class DebitorDetailServiceTest extends TestCase @@ -12,7 +12,7 @@ class DebitorDetailServiceTest extends TestCase private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorDetailService { $gateway = $gateway ?? $this->createMock(BcODataGateway::class); - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('isConfigured')->willReturn($isConfigured); return new DebitorDetailService($gateway, $settings); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php index 6630a10..a99ff0d 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php @@ -4,7 +4,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service; use MintyPHP\Module\Helpdesk\Service\BcODataGateway; use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; -use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; +use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use PHPUnit\Framework\TestCase; class DebitorSearchServiceTest extends TestCase @@ -12,7 +12,7 @@ class DebitorSearchServiceTest extends TestCase private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorSearchService { $gateway = $gateway ?? $this->createMock(BcODataGateway::class); - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('isConfigured')->willReturn($isConfigured); return new DebitorSearchService($gateway, $settings); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/EffectiveHelpdeskSettingsServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/EffectiveHelpdeskSettingsServiceTest.php new file mode 100644 index 0000000..ccbac39 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/EffectiveHelpdeskSettingsServiceTest.php @@ -0,0 +1,140 @@ +createMock(HelpdeskSettingsGateway::class); + $globalGateway->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC); + $globalGateway->method('getODataBaseUrl')->willReturn('https://global.example.com/ODataV4'); + $globalGateway->method('getCompanyName')->willReturn('Global Company'); + $globalGateway->method('getBasicUser')->willReturn('global-user'); + $globalGateway->method('getBasicPassword')->willReturn('global-pass'); + $globalGateway->method('getOAuthTenantId')->willReturn(null); + $globalGateway->method('getOAuthClientId')->willReturn(null); + $globalGateway->method('getOAuthClientSecret')->willReturn(null); + $globalGateway->method('getOAuthTokenEndpoint')->willReturn(null); + + $tenantGateway = $this->createMock(HelpdeskTenantSettingsGateway::class); + $tenantGateway->method('isOverrideEnabled')->willReturn($overrideEnabled); + $tenantGateway->method('getAuthMode')->willReturn($tenantAuthMode ?? HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); + $tenantGateway->method('getODataBaseUrl')->willReturn($tenantOdataUrl ?? 'https://tenant.example.com/ODataV4'); + $tenantGateway->method('getCompanyName')->willReturn($tenantCompanyName ?? 'Tenant Company'); + $tenantGateway->method('getBasicUser')->willReturn('tenant-user'); + $tenantGateway->method('getBasicPassword')->willReturn('tenant-pass'); + $tenantGateway->method('getOAuthTenantId')->willReturn('azure-tenant-123'); + $tenantGateway->method('getOAuthClientId')->willReturn('client-abc'); + $tenantGateway->method('getOAuthClientSecret')->willReturn('secret-xyz'); + $tenantGateway->method('getOAuthTokenEndpoint')->willReturn('https://login.microsoftonline.com/tenant/oauth2/v2.0/token'); + $tenantGateway->method('getSystemRecommendationsConfig')->willReturn(null); + $tenantGateway->method('getControllingRiskConfig')->willReturn(null); + + $sessionStore = $this->createMock(SessionStoreInterface::class); + $sessionStore->method('all')->willReturn([ + 'current_tenant' => $tenantId > 0 ? ['id' => $tenantId] : [], + ]); + + return new EffectiveHelpdeskSettingsService($globalGateway, $tenantGateway, $sessionStore); + } + + public function testFallsBackToGlobalWhenNoTenantInSession(): void + { + $resolver = $this->createResolver(tenantId: 0); + $settings = $resolver->resolveForCurrentTenant(); + + $this->assertSame('global', $settings->source); + $this->assertSame('https://global.example.com/ODataV4', $settings->odataBaseUrl); + $this->assertSame('Global Company', $settings->companyName); + $this->assertSame('global-user', $settings->basicUser); + } + + public function testFallsBackToGlobalWhenOverrideDisabled(): void + { + $resolver = $this->createResolver(tenantId: 5, overrideEnabled: false); + $settings = $resolver->resolveForCurrentTenant(); + + $this->assertSame('global', $settings->source); + $this->assertSame('https://global.example.com/ODataV4', $settings->odataBaseUrl); + $this->assertSame(HelpdeskSettingsGateway::AUTH_MODE_BASIC, $settings->authMode); + } + + public function testUsesTenantConfigWhenOverrideEnabled(): void + { + $resolver = $this->createResolver(tenantId: 5, overrideEnabled: true); + $settings = $resolver->resolveForCurrentTenant(); + + $this->assertSame('tenant', $settings->source); + $this->assertSame('https://tenant.example.com/ODataV4', $settings->odataBaseUrl); + $this->assertSame('Tenant Company', $settings->companyName); + $this->assertSame(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, $settings->authMode); + $this->assertSame('client-abc', $settings->oauthClientId); + $this->assertSame('secret-xyz', $settings->oauthClientSecret); + } + + public function testResolveForTenantDirectly(): void + { + $resolver = $this->createResolver(tenantId: 0, overrideEnabled: true); + // Calling resolveForTenant directly with a tenant ID, ignoring session + $settings = $resolver->resolveForTenant(5); + + $this->assertSame('tenant', $settings->source); + $this->assertSame('Tenant Company', $settings->companyName); + } + + public function testResolveForTenantFallsBackForZeroId(): void + { + $resolver = $this->createResolver(tenantId: 0, overrideEnabled: true); + $settings = $resolver->resolveForTenant(0); + + $this->assertSame('global', $settings->source); + } + + public function testProxyMethodsReturnResolvedValues(): void + { + $resolver = $this->createResolver(tenantId: 5, overrideEnabled: true); + + $this->assertSame(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, $resolver->getAuthMode()); + $this->assertSame('https://tenant.example.com/ODataV4', $resolver->getODataBaseUrl()); + $this->assertSame('Tenant Company', $resolver->getCompanyName()); + $this->assertSame('client-abc', $resolver->getOAuthClientId()); + $this->assertSame('secret-xyz', $resolver->getOAuthClientSecret()); + $this->assertTrue($resolver->isConfigured()); + } + + public function testBuildEntityUrlUsesResolvedConfig(): void + { + $resolver = $this->createResolver(tenantId: 5, overrideEnabled: true, tenantCompanyName: 'Acme'); + $url = $resolver->buildEntityUrl('Customers'); + + $this->assertStringContainsString('tenant.example.com', $url); + $this->assertStringContainsString('Acme', $url); + $this->assertStringContainsString('Customers', $url); + } + + public function testValueObjectIsConfiguredWithBasicAuth(): void + { + $resolver = $this->createResolver( + tenantId: 5, + overrideEnabled: true, + tenantAuthMode: HelpdeskSettingsGateway::AUTH_MODE_BASIC, + ); + $settings = $resolver->resolveForCurrentTenant(); + + // Tenant basic user/password are set, so it should be configured + $this->assertTrue($settings->isConfigured()); + $this->assertSame([], $settings->validateConfiguration()); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php index 1bb784c..669e94b 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php @@ -2,6 +2,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service; +use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository; @@ -11,7 +12,7 @@ class HelpdeskOAuthTokenServiceTest extends TestCase { private function createService(?string $authMode = null, ?string $cachedToken = null): HelpdeskOAuthTokenService { - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('getAuthMode')->willReturn($authMode ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC); $tokenRepo = $this->createMock(HelpdeskTokenRepository::class); @@ -46,7 +47,7 @@ class HelpdeskOAuthTokenServiceTest extends TestCase public function testDoesNotQueryCacheWhenAuthModeIsBasic(): void { - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC); $tokenRepo = $this->createMock(HelpdeskTokenRepository::class); @@ -58,7 +59,7 @@ class HelpdeskOAuthTokenServiceTest extends TestCase public function testDoesNotQueryCacheForInvalidTenantId(): void { - $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings = $this->createMock(EffectiveHelpdeskSettingsService::class); $settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); $tokenRepo = $this->createMock(HelpdeskTokenRepository::class); diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 00325e3..323fa08 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -529,11 +529,6 @@ margin-bottom: calc(var(--app-spacing) * 0.75); } - .helpdesk-risk-radar-search { - width: 100%; - max-width: 20rem; - } - .helpdesk-risk-radar-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); @@ -543,6 +538,7 @@ .helpdesk-risk-card { border: 1px solid var(--app-muted-border-color); + border-left: 3px solid var(--app-muted-border-color); border-radius: var(--app-radius, 0.375rem); padding: calc(var(--app-spacing) * 0.65) calc(var(--app-spacing) * 0.75); margin-bottom: 0; @@ -550,9 +546,33 @@ cursor: pointer; } + .helpdesk-risk-card.helpdesk-risk-level-high { + border-left-color: var(--app-danger, #dc3545); + } + + .helpdesk-risk-card.helpdesk-risk-level-medium { + border-left-color: var(--app-warning, #f59e0b); + } + + .helpdesk-risk-card.helpdesk-risk-level-low { + border-left-color: var(--app-success, #198754); + } + .helpdesk-risk-card:hover { - border-color: var(--app-primary, #0d6efd); - box-shadow: 0 1px 4px color-mix(in srgb, var(--app-primary, #0d6efd) 15%, transparent); + border-color: var(--app-muted-border-color); + box-shadow: 0 2px 8px color-mix(in srgb, currentColor 6%, transparent); + } + + .helpdesk-risk-card.helpdesk-risk-level-high:hover { + border-left-color: var(--app-danger, #dc3545); + } + + .helpdesk-risk-card.helpdesk-risk-level-medium:hover { + border-left-color: var(--app-warning, #f59e0b); + } + + .helpdesk-risk-card.helpdesk-risk-level-low:hover { + border-left-color: var(--app-success, #198754); } .helpdesk-risk-card:focus-visible { @@ -562,28 +582,36 @@ .helpdesk-risk-card-header { display: flex; - align-items: flex-start; + align-items: center; + justify-content: space-between; gap: calc(var(--app-spacing) * 0.6); } .helpdesk-risk-card-score { font-variant-numeric: tabular-nums; - font-weight: 700; - font-size: 1.5rem; + font-weight: 600; + font-size: var(--text-xs, 0.75rem); flex-shrink: 0; line-height: 1; + padding: 0.2em 0.55em; + border-radius: 999px; + display: inline-flex; + align-items: center; } .helpdesk-risk-card-score.helpdesk-risk-level-high { color: var(--app-danger, #dc3545); + background: color-mix(in srgb, var(--app-danger, #dc3545) 10%, transparent); } .helpdesk-risk-card-score.helpdesk-risk-level-medium { - color: var(--app-warning, #f59e0b); + color: color-mix(in srgb, var(--app-warning, #f59e0b) 85%, black); + background: color-mix(in srgb, var(--app-warning, #f59e0b) 12%, transparent); } .helpdesk-risk-card-score.helpdesk-risk-level-low { color: var(--app-success, #198754); + background: color-mix(in srgb, var(--app-success, #198754) 10%, transparent); } .helpdesk-risk-card-info { @@ -603,18 +631,7 @@ .helpdesk-risk-card-level { font-size: var(--text-xs, 0.75rem); font-weight: 500; - } - - .helpdesk-risk-card-level.helpdesk-risk-level-high { - color: var(--app-danger, #dc3545); - } - - .helpdesk-risk-card-level.helpdesk-risk-level-medium { - color: var(--app-warning, #f59e0b); - } - - .helpdesk-risk-card-level.helpdesk-risk-level-low { - color: var(--app-success, #198754); + color: var(--app-muted-color); } .helpdesk-risk-card-facts { diff --git a/modules/helpdesk/web/js/helpdesk-risk-radar.js b/modules/helpdesk/web/js/helpdesk-risk-radar.js index 9ba671b..7e04408 100644 --- a/modules/helpdesk/web/js/helpdesk-risk-radar.js +++ b/modules/helpdesk/web/js/helpdesk-risk-radar.js @@ -16,7 +16,6 @@ if (container) { const elCards = document.getElementById('radar-cards'); const elRefresh = container.querySelector('button[name="radar-refresh"]'); const periodSelector = document.getElementById('radar-period-selector'); - const searchInput = document.getElementById('radar-search'); const dialog = document.getElementById('radar-detail-dialog'); const dialogTitle = document.getElementById('radar-detail-title'); const dialogBody = document.getElementById('radar-detail-body'); @@ -32,11 +31,6 @@ if (container) { if (elContent) elContent.hidden = state !== 'success'; } - function setText(id, value) { - const el = document.getElementById(id); - if (el) el.textContent = value; - } - function h(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; @@ -68,21 +62,21 @@ if (container) { } /** - * Build a single customer risk card — Stripe-style: score dominant, one-line facts. + * Build a single customer risk card — accent border + score pill. */ function buildCard(customer) { const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level)); card.setAttribute('aria-label', customer.customer_name || customer.customer_no); - // Row 1: Score (large, colored) + Name + Level + // Row 1: Name + Level on left, score pill on right const header = h('div', 'helpdesk-risk-card-header'); - const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score)); - header.appendChild(scoreEl); const info = h('div', 'helpdesk-risk-card-info'); info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no)); const levelLabels = { high: d('labelHigh', 'High risk'), medium: d('labelMedium', 'Medium risk'), low: d('labelLow', 'Low risk') }; - info.appendChild(h('span', 'helpdesk-risk-card-level ' + levelClass(customer.risk_level), levelLabels[customer.risk_level] || '')); + info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || '')); header.appendChild(info); + const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score)); + header.appendChild(scoreEl); card.appendChild(header); // Row 2: One-line key facts separated by · @@ -187,28 +181,12 @@ if (container) { if (dialogClose) dialogClose.addEventListener('click', () => dialog.close()); if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); - function renderKpis(summary) { - setText('radar-kpi-total', summary.total ?? 0); - setText('radar-kpi-high', summary.high ?? 0); - setText('radar-kpi-medium', summary.medium ?? 0); - setText('radar-kpi-low', summary.low ?? 0); - } - function renderCards(customers) { if (!elCards) return; elCards.replaceChildren(); for (const c of customers) elCards.appendChild(buildCard(c)); } - function filterCards(query) { - if (!lastData) return; - const q = query.toLowerCase().trim(); - const filtered = q === '' - ? lastData.customers - : lastData.customers.filter(c => (c.customer_name || '').toLowerCase().includes(q) || c.customer_no.toLowerCase().includes(q)); - renderCards(filtered); - } - async function fetchData(refresh = false) { showState('loading'); if (elTruncated) elTruncated.hidden = true; @@ -224,7 +202,6 @@ if (container) { if (!json.customers || json.customers.length === 0) { showState('empty'); return; } lastData = json; - renderKpis(json.summary); renderCards(json.customers); showState('success'); @@ -247,10 +224,6 @@ if (container) { }); } - if (searchInput) { - searchInput.addEventListener('input', () => filterCards(searchInput.value)); - } - if (elRefresh) { elRefresh.addEventListener('click', () => fetchData(true)); } diff --git a/modules/helpdesk/web/js/helpdesk-settings.js b/modules/helpdesk/web/js/helpdesk-settings.js index ee65b82..6ea81e0 100644 --- a/modules/helpdesk/web/js/helpdesk-settings.js +++ b/modules/helpdesk/web/js/helpdesk-settings.js @@ -68,6 +68,38 @@ const parseJsonSafely = async (response) => { } }; +// Tenant override toggle — show/hide tenant fields + set save_target +const overrideToggle = document.getElementById('helpdesk-override-toggle'); +const tenantFields = document.getElementById('helpdesk-tenant-fields'); +const saveTargetInput = document.getElementById('helpdesk-settings-save-target'); + +const tenantAuthModeSelect = document.getElementById('tenant_auth_mode'); +const tenantBasicFields = document.getElementById('helpdesk-tenant-basic-auth-fields'); +const tenantOauth2Fields = document.getElementById('helpdesk-tenant-oauth2-fields'); + +if (overrideToggle && tenantFields) { + overrideToggle.addEventListener('change', () => { + tenantFields.hidden = !overrideToggle.checked; + }); +} + +// On form submit, set save_target based on active tab +if (settingsForm && saveTargetInput) { + settingsForm.addEventListener('submit', () => { + const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])'); + saveTargetInput.value = tenantPanel ? 'tenant' : 'global'; + }); +} + +// Tenant auth mode toggle +if (tenantAuthModeSelect && tenantBasicFields && tenantOauth2Fields) { + tenantAuthModeSelect.addEventListener('change', () => { + const isOAuth2 = tenantAuthModeSelect.value === 'oauth2'; + tenantBasicFields.hidden = isOAuth2; + tenantOauth2Fields.hidden = !isOAuth2; + }); +} + if (testButton) { testButton.addEventListener('click', async () => { setButtonLoadingState(true);