feat(helpdesk): add multi-tenant BC connection settings with risk radar refinements
Introduce per-tenant override for helpdesk BC connection config. New table helpdesk_tenant_settings stores tenant-specific credentials with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService resolves global vs tenant config per request. Settings UI extended with tenant override tab, toggle, and dual editing mode. All runtime consumers (OData, SOAP, OAuth) read through the new resolver. Token cache flushed on any config change. Default behavior unchanged — tenants use global config until override explicitly enabled. Also includes risk radar UI refinements: Stripe-style card redesign with accent borders and score pills, removal of redundant KPI tiles and search, and filtering of zero-score entries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -15,7 +15,7 @@ class DebitorDetailService
|
||||
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
private readonly EffectiveHelpdeskSettingsService $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class DebitorSearchService
|
||||
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
private readonly EffectiveHelpdeskSettingsService $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Immutable value object holding the fully resolved helpdesk BC connection config.
|
||||
*
|
||||
* Created by EffectiveHelpdeskSettingsService — consumers read this instead
|
||||
* of reaching into global or tenant settings directly.
|
||||
*/
|
||||
final class EffectiveHelpdeskSettings
|
||||
{
|
||||
/**
|
||||
* @param 'global'|'tenant' $source
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $source,
|
||||
public readonly string $authMode,
|
||||
public readonly string $odataBaseUrl,
|
||||
public readonly string $companyName,
|
||||
public readonly ?string $basicUser,
|
||||
public readonly ?string $basicPassword,
|
||||
public readonly ?string $oauthTenantId,
|
||||
public readonly ?string $oauthClientId,
|
||||
public readonly ?string $oauthClientSecret,
|
||||
public readonly ?string $oauthTokenEndpoint,
|
||||
public readonly ?string $systemRecommendationsConfigRaw,
|
||||
public readonly ?string $controllingRiskConfigRaw,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildEntityUrl(string $entitySet): string
|
||||
{
|
||||
$company = rawurlencode($this->companyName);
|
||||
|
||||
return $this->odataBaseUrl . "/Company('" . $company . "')/" . $entitySet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string> 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() === [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
|
||||
/**
|
||||
* Resolves the effective helpdesk BC config for the current request.
|
||||
*
|
||||
* Resolution rule:
|
||||
* - If the current tenant has an active override (override_enabled=1): tenant config.
|
||||
* - Otherwise: global config from the settings table.
|
||||
*
|
||||
* All runtime consumers (OData, SOAP, OAuth) should read through this service
|
||||
* instead of accessing HelpdeskSettingsGateway directly.
|
||||
*/
|
||||
class EffectiveHelpdeskSettingsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HelpdeskSettingsGateway $globalGateway,
|
||||
private readonly HelpdeskTenantSettingsGateway $tenantGateway,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective settings for the given tenant.
|
||||
*/
|
||||
public function resolveForTenant(int $tenantId): EffectiveHelpdeskSettings
|
||||
{
|
||||
if ($tenantId > 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<string>
|
||||
*/
|
||||
public function validateConfiguration(): array
|
||||
{
|
||||
return $this->resolveForCurrentTenant()->validateConfiguration();
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->resolveForCurrentTenant()->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* config: array<string, mixed>,
|
||||
* source: 'default'|'settings'|'fallback_invalid_json'
|
||||
* }
|
||||
*/
|
||||
public function getSystemRecommendationsConfigEnvelope(): array
|
||||
{
|
||||
return $this->globalGateway->getSystemRecommendationsConfigEnvelope();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSystemRecommendationsConfig(): array
|
||||
{
|
||||
return $this->globalGateway->getSystemRecommendationsConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* config: array<string, mixed>,
|
||||
* source: 'default'|'settings'|'fallback_invalid_json'
|
||||
* }
|
||||
*/
|
||||
public function getControllingRiskConfigEnvelope(): array
|
||||
{
|
||||
return $this->globalGateway->getControllingRiskConfigEnvelope();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
|
||||
/**
|
||||
* Gateway for per-tenant helpdesk BC connection settings.
|
||||
*
|
||||
* Wraps the tenant settings repository and handles encryption/decryption
|
||||
* of secrets (basic_password, oauth_client_secret) via SettingsCryptoGateway.
|
||||
*/
|
||||
class HelpdeskTenantSettingsGateway
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HelpdeskTenantSettingsRepository $repository,
|
||||
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an active override exists for the given tenant.
|
||||
*/
|
||||
public function isOverrideEnabled(int $tenantId): bool
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
if ($row === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($row['override_enabled'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/**
|
||||
* Repository for per-tenant helpdesk BC connection overrides (helpdesk_tenant_settings table).
|
||||
*
|
||||
* All queries are scoped by tenant_id. No cross-tenant access is possible.
|
||||
*/
|
||||
class HelpdeskTenantSettingsRepository
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>|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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $data
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
21
modules/helpdesk/migrations/002_create_tenant_settings.sql
Normal file
21
modules/helpdesk/migrations/002_create_tenant_settings.sql
Normal file
@@ -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;
|
||||
@@ -31,7 +31,6 @@ $periodLabels = [
|
||||
data-label-open="<?php e(t('open')); ?>"
|
||||
data-label-overdue="<?php e(t('overdue')); ?>"
|
||||
data-label-net="<?php e(t('net')); ?>"
|
||||
data-label-search="<?php e(t('Search customers...')); ?>"
|
||||
data-label-critical="<?php e(t('critical')); ?>"
|
||||
data-label-ticket="<?php e(t('Ticket')); ?>"
|
||||
data-label-status="<?php e(t('Status')); ?>"
|
||||
@@ -61,7 +60,6 @@ $periodLabels = [
|
||||
|
||||
<div class="helpdesk-risk-radar-dashboard">
|
||||
<div class="helpdesk-risk-radar-toolbar">
|
||||
<input type="search" id="radar-search" class="helpdesk-risk-radar-search" placeholder="<?php e(t('Search customers...')); ?>" aria-label="<?php e(t('Search customers...')); ?>">
|
||||
<div class="helpdesk-segment-control" id="radar-period-selector">
|
||||
<?php foreach ($periods as $p): ?>
|
||||
<input type="radio" name="radar-period" id="radar-period-<?php e($p); ?>" value="<?php e($p); ?>"<?php if ($p === 90): ?> checked<?php endif; ?>>
|
||||
@@ -71,17 +69,9 @@ $periodLabels = [
|
||||
</div>
|
||||
|
||||
<div id="radar-loading">
|
||||
<div class="helpdesk-support-metrics">
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton-kpi">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div class="helpdesk-risk-radar-grid">
|
||||
<?php for ($i = 0; $i < 6; $i++): ?>
|
||||
<div class="helpdesk-skeleton" style="height: 10rem; border-radius: var(--app-radius, 0.375rem);"></div>
|
||||
<div class="helpdesk-skeleton" style="height: 5rem; border-radius: var(--app-radius, 0.375rem);"></div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,25 +89,6 @@ $periodLabels = [
|
||||
</div>
|
||||
|
||||
<div id="radar-content" hidden>
|
||||
<div class="helpdesk-support-metrics" id="radar-kpis">
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Customers')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="radar-kpi-total">—</p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('High risk')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="radar-kpi-high">—</p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Medium risk')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="radar-kpi-medium">—</p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Low risk')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="radar-kpi-low">—</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="radar-cards" class="helpdesk-risk-radar-grid"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
@@ -13,6 +16,13 @@ Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SETTINGS_M
|
||||
|
||||
$request = requestInput();
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
$tenantSettingsGateway = app(HelpdeskTenantSettingsGateway::class);
|
||||
$tokenRepository = app(HelpdeskTokenRepository::class);
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->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 = [
|
||||
|
||||
@@ -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;
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
@@ -109,12 +123,16 @@ $isOAuth2 = $authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
data-test-default-label="<?php e(t('Test connection')); ?>"
|
||||
>
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
<input type="hidden" name="save_target" value="global" id="helpdesk-settings-save-target">
|
||||
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-settings-tabs-v2">
|
||||
<div class="app-tabs-nav">
|
||||
<button type="button" data-tab="connection" data-tab-default><?php e(t('Connection')); ?></button>
|
||||
<button type="button" data-tab="auth"><?php e(t('Authentication')); ?></button>
|
||||
<button type="button" data-tab="automation"><?php e(t('Automation')); ?></button>
|
||||
<?php if ($currentTenantId > 0): ?>
|
||||
<button type="button" data-tab="tenant"><?php e(t('Tenant override')); ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div data-tab-panel="connection" class="helpdesk-settings-panel">
|
||||
@@ -408,6 +426,120 @@ $isOAuth2 = $authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<?php if ($currentTenantId > 0): ?>
|
||||
<div data-tab-panel="tenant" class="helpdesk-settings-panel">
|
||||
<details class="app-details-card" name="helpdesk-tenant-config-source" data-details-key="helpdesk-settings-tenant-source" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Configuration source')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="helpdesk-settings-rule">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
name="override_enabled"
|
||||
id="helpdesk-override-toggle"
|
||||
<?php if ($tenantOverrideEnabled): ?>checked<?php endif; ?>
|
||||
>
|
||||
<span class="helpdesk-settings-rule-label"><?php e(t('Use tenant-specific configuration')); ?></span>
|
||||
<small><?php e(t('When enabled, this tenant uses its own BC connection instead of the global configuration.')); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="helpdesk-tenant-fields" <?php if (!$tenantOverrideEnabled): ?>hidden<?php endif; ?>>
|
||||
<details class="app-details-card" name="helpdesk-tenant-connection" data-details-key="helpdesk-settings-tenant-connection" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Tenant connection')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field" for="tenant_odata_base_url">
|
||||
<span><?php e(t('OData Base URL')); ?></span>
|
||||
<input
|
||||
type="url"
|
||||
id="tenant_odata_base_url"
|
||||
name="tenant_odata_base_url"
|
||||
value="<?php e(trim((string) ($tenantRow['odata_base_url'] ?? ''))); ?>"
|
||||
placeholder="https://bc.example.com:7048/BusinessCentral/ODataV4"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="app-field" for="tenant_company_name">
|
||||
<span><?php e(t('Company name')); ?></span>
|
||||
<input
|
||||
type="text"
|
||||
id="tenant_company_name"
|
||||
name="tenant_company_name"
|
||||
value="<?php e(trim((string) ($tenantRow['company_name'] ?? ''))); ?>"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-tenant-auth" data-details-key="helpdesk-settings-tenant-auth" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Tenant authentication')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field" for="tenant_auth_mode">
|
||||
<span><?php e(t('Auth mode')); ?></span>
|
||||
<select id="tenant_auth_mode" name="tenant_auth_mode">
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if (!$tenantIsOAuth2): ?>selected<?php endif; ?>>
|
||||
<?php e(t('Basic Auth')); ?>
|
||||
</option>
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($tenantIsOAuth2): ?>selected<?php endif; ?>>
|
||||
<?php e(t('OAuth2 (client credentials)')); ?>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div id="helpdesk-tenant-basic-auth-fields" class="helpdesk-settings-rule-grid" <?php if ($tenantIsOAuth2): ?>hidden<?php endif; ?>>
|
||||
<label class="app-field" for="tenant_basic_user">
|
||||
<span><?php e(t('Username')); ?></span>
|
||||
<input type="text" id="tenant_basic_user" name="tenant_basic_user" value="<?php e(trim((string) ($tenantRow['basic_user'] ?? ''))); ?>" autocomplete="off">
|
||||
</label>
|
||||
<label class="app-field" for="tenant_basic_password">
|
||||
<span><?php e(t('Password')); ?></span>
|
||||
<input type="password" id="tenant_basic_password" name="tenant_basic_password" value="<?php if ($tenantHasBasicPassword): ?>********<?php endif; ?>" autocomplete="new-password">
|
||||
<?php if ($tenantHasBasicPassword): ?>
|
||||
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="helpdesk-tenant-oauth2-fields" class="helpdesk-settings-rule-grid" <?php if (!$tenantIsOAuth2): ?>hidden<?php endif; ?>>
|
||||
<label class="app-field" for="tenant_oauth_tenant_id">
|
||||
<span><?php e(t('Azure Tenant ID')); ?></span>
|
||||
<input type="text" id="tenant_oauth_tenant_id" name="tenant_oauth_tenant_id" value="<?php e(trim((string) ($tenantRow['oauth_tenant_id'] ?? ''))); ?>" autocomplete="off">
|
||||
</label>
|
||||
<label class="app-field" for="tenant_oauth_client_id">
|
||||
<span><?php e(t('Client ID')); ?></span>
|
||||
<input type="text" id="tenant_oauth_client_id" name="tenant_oauth_client_id" value="<?php e(trim((string) ($tenantRow['oauth_client_id'] ?? ''))); ?>" autocomplete="off">
|
||||
</label>
|
||||
<label class="app-field" for="tenant_oauth_client_secret">
|
||||
<span><?php e(t('Client Secret')); ?></span>
|
||||
<input type="password" id="tenant_oauth_client_secret" name="tenant_oauth_client_secret" value="<?php if ($tenantHasOAuthSecret): ?>********<?php endif; ?>" autocomplete="new-password">
|
||||
<?php if ($tenantHasOAuthSecret): ?>
|
||||
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<label class="app-field" for="tenant_oauth_token_endpoint">
|
||||
<span><?php e(t('Token endpoint URL')); ?></span>
|
||||
<input type="url" id="tenant_oauth_token_endpoint" name="tenant_oauth_token_endpoint" value="<?php e(trim((string) ($tenantRow['oauth_token_endpoint'] ?? ''))); ?>" placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<?php if (!$tenantOverrideEnabled): ?>
|
||||
<div class="notice" data-variant="info" role="status">
|
||||
<p><?php e(t('This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.')); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EffectiveHelpdeskSettingsServiceTest extends TestCase
|
||||
{
|
||||
private function createResolver(
|
||||
int $tenantId = 0,
|
||||
bool $overrideEnabled = false,
|
||||
?string $tenantAuthMode = null,
|
||||
?string $tenantOdataUrl = null,
|
||||
?string $tenantCompanyName = null,
|
||||
): EffectiveHelpdeskSettingsService {
|
||||
$globalGateway = $this->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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user