From 6ac685084def2948fe2eefacb42c8e72acd1a2ab Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 21 Apr 2026 13:43:16 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Security=20Level=20mit=20Notiz=20f?= =?UTF-8?q?=C3=BCr=20Domains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert ein Dialog-basiertes UI zum Bearbeiten des Security Levels mit optionaler Notiz-Funktion für Helpdesk-Domains. Backend: - DomainSecurityLevelRepository: CRUD für security_levels mit note-Spalte - DomainSecurityLevelService: Business-Logik mit Batch-Enrichment - security-level-data() Endpoint: AJAX + Full-Page POST mit CSRF - Migration 010: note TEXT-Spalte hinzugefügt - Bugfix: findByTenantAndDomainNo gibt null bei leerem Array zurück Frontend (Domain-Liste): - Badge in Grid klickbar → öffnet Modal-Dialog - Dialog mit Level-Select + Notiz-Textarea - AJAX-Save mit Live-Badge-Update ohne Reload - Event-Delegation für dynamische Grid-Inhalte Frontend (Domain-Detail): - Notiz-Feld im Security-Level-Formular - PRG-Pattern mit Flash-Messages Core: - app-http.js: DEFAULT_HEADERS auf XMLHttpRequest für CSRF-Kompatibilität i18n: - Keys: Priority, Note, Optional note, Security level updated Tests: - DomainSecurityLevelServiceTest: CRUD + Batch-Enrichment --- i18n/default_de.json | 6 +- i18n/default_en.json | 6 +- modules/helpdesk/i18n/default_de.json | 7 +- modules/helpdesk/i18n/default_en.json | 7 +- .../Helpdesk/HelpdeskContainerRegistrar.php | 8 + .../DomainSecurityLevelRepository.php | 155 +++++++++++++ .../Service/DomainSecurityLevelService.php | 124 ++++++++++ .../009_create_domain_security_levels.sql | 16 ++ .../010_add_security_level_note.sql | 5 + modules/helpdesk/module.php | 1 + .../helpdesk/pages/helpdesk/domain($id).php | 10 + .../pages/helpdesk/domain(default).phtml | 62 ++++- .../pages/helpdesk/domain-detail-data().php | 7 + .../pages/helpdesk/domains-data().php | 41 +++- .../pages/helpdesk/domains/filter-schema.php | 16 ++ .../pages/helpdesk/domains/index().php | 6 + .../helpdesk/domains/index(default).phtml | 37 +++ .../domains/security-level-data().php | 99 ++++++++ .../DomainSecurityLevelServiceTest.php | 141 ++++++++++++ modules/helpdesk/web/css/helpdesk.css | 14 ++ .../helpdesk/web/js/helpdesk-domain-detail.js | 52 ++++- .../web/js/pages/helpdesk-domains-index.js | 214 +++++++++++++++++- web/js/core/app-http.js | 2 +- 23 files changed, 1017 insertions(+), 19 deletions(-) create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Repository/DomainSecurityLevelRepository.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/DomainSecurityLevelService.php create mode 100644 modules/helpdesk/migrations/009_create_domain_security_levels.sql create mode 100644 modules/helpdesk/migrations/010_add_security_level_note.sql create mode 100644 modules/helpdesk/pages/helpdesk/domains/security-level-data().php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/DomainSecurityLevelServiceTest.php diff --git a/i18n/default_de.json b/i18n/default_de.json index 61e7300..e30a491 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1259,5 +1259,9 @@ "Delete this image?": "Dieses Bild löschen?", "Current image": "Aktuelles Bild", "Open navigation": "Navigation öffnen", - "Close navigation": "Navigation schließen" + "Close navigation": "Navigation schließen", + "Priority": "Priorität", + "Note": "Notiz", + "Optional note...": "Optionale Notiz...", + "Security level updated": "Sicherheitsstufe aktualisiert" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 5f52b70..d979c35 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1259,5 +1259,9 @@ "Delete this image?": "Delete this image?", "Current image": "Current image", "Open navigation": "Open navigation", - "Close navigation": "Close navigation" + "Close navigation": "Close navigation", + "Priority": "Priority", + "Note": "Note", + "Optional note...": "Optional note...", + "Security level updated": "Security level updated" } diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 74a6df1..0ca5f29 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -540,5 +540,10 @@ "Invalid category": "Ungültige Kategorie", "Ticket number is required": "Ticket-Nummer ist erforderlich", "View software updates list": "Software-Updates-Liste anzeigen", - "Assign and manage software updates": "Software-Updates zuweisen und verwalten" + "Assign and manage software updates": "Software-Updates zuweisen und verwalten", + "Security level": "Sicherheitsstufe", + "Low": "Niedrig", + "Normal": "Normal", + "High": "Hoch", + "Critical": "Kritisch" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index bcb88c0..18d0c3c 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -540,5 +540,10 @@ "Invalid category": "Invalid category", "Ticket number is required": "Ticket number is required", "View software updates list": "View software updates list", - "Assign and manage software updates": "Assign and manage software updates" + "Assign and manage software updates": "Assign and manage software updates", + "Security level": "Security level", + "Low": "Low", + "Normal": "Normal", + "High": "High", + "Critical": "Critical" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index d0ef304..f3b9d38 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -21,10 +21,12 @@ use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService; use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine; use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler; +use MintyPHP\Module\Helpdesk\Repository\DomainSecurityLevelRepository; use MintyPHP\Module\Helpdesk\Repository\HandoverRepository; use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository; use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository; use MintyPHP\Module\Helpdesk\Repository\UpdateRepository; +use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService; use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService; use MintyPHP\Module\Helpdesk\Service\HandoverService; use MintyPHP\Module\Helpdesk\Service\UpdateService; @@ -119,6 +121,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(SoftwareProductSyncService::class) )); + $container->set(DomainSecurityLevelRepository::class, static fn (): DomainSecurityLevelRepository => new DomainSecurityLevelRepository()); + + $container->set(DomainSecurityLevelService::class, static fn (AppContainer $c): DomainSecurityLevelService => new DomainSecurityLevelService( + $c->get(DomainSecurityLevelRepository::class) + )); + $container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository()); $container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository()); diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/DomainSecurityLevelRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/DomainSecurityLevelRepository.php new file mode 100644 index 0000000..c7f5d42 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/DomainSecurityLevelRepository.php @@ -0,0 +1,155 @@ + $domainNos + * @return array Map of domain_no => security_level + */ + public function listLevelsByDomainNos(int $tenantId, array $domainNos): array + { + if ($domainNos === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($domainNos), '?')); + $params = array_map('strval', $domainNos); + $params[] = (string) $tenantId; + + $rows = DB::select( + 'SELECT domain_no, security_level FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no IN (' . $placeholders . ')', + ...$params + ); + + $result = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $domainNo = (string) ($row['domain_no'] ?? ''); + $level = (string) ($row['security_level'] ?? 'normal'); + if ($domainNo !== '') { + $result[$domainNo] = in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal'; + } + } + } + + return $result; + } + + /** + * @param list $domainNos + * @return array Map of domain_no => {level, note} + */ + public function listDetailsByDomainNos(int $tenantId, array $domainNos): array + { + if ($domainNos === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($domainNos), '?')); + $params = array_map('strval', $domainNos); + $params[] = (string) $tenantId; + + $rows = DB::select( + 'SELECT domain_no, security_level, note FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no IN (' . $placeholders . ')', + ...$params + ); + + $result = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $domainNo = (string) ($row['domain_no'] ?? ''); + $level = (string) ($row['security_level'] ?? 'normal'); + $note = (string) ($row['note'] ?? ''); + if ($domainNo !== '') { + $result[$domainNo] = [ + 'level' => in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal', + 'note' => $note, + ]; + } + } + } + + return $result; + } + + /** + * Insert or update security level for a domain. + * + * @return bool True on success + */ + public function upsert(int $tenantId, string $domainNo, string $level, ?int $userId, ?string $note = null): bool + { + if ($domainNo === '' || !in_array($level, self::ALLOWED_LEVELS, true)) { + return false; + } + + $existing = $this->findByTenantAndDomainNo($tenantId, $domainNo); + + if ($existing !== null) { + $result = DB::update( + 'UPDATE helpdesk_domain_security_levels SET security_level = ?, note = ?, updated_by = ? WHERE tenant_id = ? AND domain_no = ?', + $level, + $note, + $userId !== null ? (string) $userId : null, + (string) $tenantId, + $domainNo + ); + + return $result !== false; + } + + $result = DB::insert( + 'INSERT INTO helpdesk_domain_security_levels (tenant_id, domain_no, security_level, note, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?)', + (string) $tenantId, + $domainNo, + $level, + $note, + $userId !== null ? (string) $userId : null, + $userId !== null ? (string) $userId : null + ); + + return $result !== false; + } + + /** + * @api Reserved for future cleanup jobs + */ + public function deleteByTenantAndDomainNo(int $tenantId, string $domainNo): bool + { + if ($domainNo === '') { + return false; + } + + $result = DB::update( + 'DELETE FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no = ?', + (string) $tenantId, + $domainNo + ); + + return $result !== false; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DomainSecurityLevelService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DomainSecurityLevelService.php new file mode 100644 index 0000000..548366e --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DomainSecurityLevelService.php @@ -0,0 +1,124 @@ + 'info', + 'normal' => 'neutral', + 'hoch' => 'warning', + 'kritisch' => 'error', + ]; + + public function __construct( + private readonly DomainSecurityLevelRepository $repository + ) { + } + + /** + * @api Called from page actions and AJAX endpoints + */ + public function getLevel(int $tenantId, string $domainNo): string + { + if ($domainNo === '') { + return 'normal'; + } + + $row = $this->repository->findByTenantAndDomainNo($tenantId, $domainNo); + + if ($row === null) { + return 'normal'; + } + + $level = (string) ($row['security_level'] ?? 'normal'); + + return in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal'; + } + + /** + * @api Called from AJAX endpoints — returns level + note + * @return array{level: string, note: string} + */ + public function getDetails(int $tenantId, string $domainNo): array + { + if ($domainNo === '') { + return ['level' => 'normal', 'note' => '']; + } + + $row = $this->repository->findByTenantAndDomainNo($tenantId, $domainNo); + + if ($row === null) { + return ['level' => 'normal', 'note' => '']; + } + + $level = (string) ($row['security_level'] ?? 'normal'); + $note = (string) ($row['note'] ?? ''); + + return [ + 'level' => in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal', + 'note' => $note, + ]; + } + + /** + * @api Called from page actions and AJAX endpoints + */ + public function setLevel(int $tenantId, string $domainNo, string $level, ?int $userId, ?string $note = null): bool + { + if ($domainNo === '' || !in_array($level, self::ALLOWED_LEVELS, true)) { + return false; + } + + return $this->repository->upsert($tenantId, $domainNo, $level, $userId, $note); + } + + /** + * @api Called from domains-data endpoint for batch enrichment + */ + public function getAllLevelsForDomains(int $tenantId, array $domainNos): array + { + if ($domainNos === []) { + return []; + } + + return $this->repository->listLevelsByDomainNos($tenantId, $domainNos); + } + + /** + * @api Called from domains-data endpoint for batch enrichment with note + * @param list $domainNos + * @return array + */ + public function getAllDetailsForDomains(int $tenantId, array $domainNos): array + { + if ($domainNos === []) { + return []; + } + + return $this->repository->listDetailsByDomainNos($tenantId, $domainNos); + } + + /** + * @api Called from views and AJAX responses for badge rendering + */ + public static function getBadgeVariant(string $level): string + { + return self::BADGE_VARIANTS[$level] ?? 'neutral'; + } + + /** + * @return list + */ + /** + * @api Called from AJAX endpoint for validation + */ + public static function getAllowedLevels(): array + { + return self::ALLOWED_LEVELS; + } +} diff --git a/modules/helpdesk/migrations/009_create_domain_security_levels.sql b/modules/helpdesk/migrations/009_create_domain_security_levels.sql new file mode 100644 index 0000000..4c3bb95 --- /dev/null +++ b/modules/helpdesk/migrations/009_create_domain_security_levels.sql @@ -0,0 +1,16 @@ +-- Add domain security levels table +-- Stores per-tenant security classification for domains (BC OData is read-only) + +CREATE TABLE IF NOT EXISTS `helpdesk_domain_security_levels` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `domain_no` VARCHAR(30) NOT NULL, + `security_level` VARCHAR(20) NOT NULL DEFAULT 'normal', + `created_by` INT UNSIGNED NULL, + `updated_by` INT UNSIGNED NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_tenant_domain` (`tenant_id`, `domain_no`), + KEY `idx_tenant_level` (`tenant_id`, `security_level`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/helpdesk/migrations/010_add_security_level_note.sql b/modules/helpdesk/migrations/010_add_security_level_note.sql new file mode 100644 index 0000000..46cc8ec --- /dev/null +++ b/modules/helpdesk/migrations/010_add_security_level_note.sql @@ -0,0 +1,5 @@ +-- Add optional note column to domain security levels +-- Allows users to add a free-text note when setting a security level + +ALTER TABLE `helpdesk_domain_security_levels` + ADD COLUMN `note` TEXT NULL DEFAULT NULL AFTER `security_level`; diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 825eb1e..4b197d0 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -17,6 +17,7 @@ return [ ['path' => 'helpdesk/search-data', 'target' => 'helpdesk/search-data'], ['path' => 'helpdesk/domains', 'target' => 'helpdesk/domains'], ['path' => 'helpdesk/domains-data', 'target' => 'helpdesk/domains-data'], + ['path' => 'helpdesk/domains/security-level-data', 'target' => 'helpdesk/domains/security-level-data'], ['path' => 'helpdesk/domain/{id}', 'target' => 'helpdesk/domain'], ['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'], ['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'], diff --git a/modules/helpdesk/pages/helpdesk/domain($id).php b/modules/helpdesk/pages/helpdesk/domain($id).php index d0e167b..dc4e864 100644 --- a/modules/helpdesk/pages/helpdesk/domain($id).php +++ b/modules/helpdesk/pages/helpdesk/domain($id).php @@ -1,8 +1,10 @@ all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); +$securityLevelService = app(DomainSecurityLevelService::class); +$securityDetails = $tenantId > 0 ? $securityLevelService->getDetails($tenantId, $domainNo) : ['level' => 'normal', 'note' => '']; +$securityLevel = $securityDetails['level']; +$securityLevelNote = $securityDetails['note']; +$securityLevelVariant = DomainSecurityLevelService::getBadgeVariant($securityLevel); + $pageTitle = $domainUrl !== '' ? $domainUrl : $domainNo; Buffer::set('title', $pageTitle . ' — ' . t('Helpdesk')); diff --git a/modules/helpdesk/pages/helpdesk/domain(default).phtml b/modules/helpdesk/pages/helpdesk/domain(default).phtml index 07f6f37..e921ad8 100644 --- a/modules/helpdesk/pages/helpdesk/domain(default).phtml +++ b/modules/helpdesk/pages/helpdesk/domain(default).phtml @@ -1,5 +1,7 @@ 'success', @@ -101,17 +109,50 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral'; - - + + +
+ + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
@@ -166,6 +207,7 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral'; +
diff --git a/modules/helpdesk/pages/helpdesk/domain-detail-data().php b/modules/helpdesk/pages/helpdesk/domain-detail-data().php index e997be5..b2e0a81 100644 --- a/modules/helpdesk/pages/helpdesk/domain-detail-data().php +++ b/modules/helpdesk/pages/helpdesk/domain-detail-data().php @@ -3,6 +3,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Module\Helpdesk\Service\DomainDetailService; +use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService; use MintyPHP\Router; use MintyPHP\Support\Guard; @@ -26,4 +27,10 @@ $tenantId = (int) ($session['current_tenant']['id'] ?? 0); $service = app(DomainDetailService::class); $result = $service->loadDomainDetails($domainNo, $customerNo, $tenantId); +$securityLevelService = app(DomainSecurityLevelService::class); +$securityDetails = $tenantId > 0 ? $securityLevelService->getDetails($tenantId, $domainNo) : ['level' => 'normal', 'note' => '']; +$result['security_level'] = $securityDetails['level']; +$result['security_level_variant'] = DomainSecurityLevelService::getBadgeVariant($securityDetails['level']); +$result['security_level_note'] = $securityDetails['note']; + Router::json($result); diff --git a/modules/helpdesk/pages/helpdesk/domains-data().php b/modules/helpdesk/pages/helpdesk/domains-data().php index 41ebce2..e4b2840 100644 --- a/modules/helpdesk/pages/helpdesk/domains-data().php +++ b/modules/helpdesk/pages/helpdesk/domains-data().php @@ -1,13 +1,18 @@ all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); + $filters = gridParseFiltersFromSchemaFile(__DIR__ . '/domains/filter-schema.php'); $search = trim((string) ($filters['search'] ?? '')); @@ -17,6 +22,8 @@ $state = trim((string) ($filters['state'] ?? '')); $state = $state === 'all' ? '' : $state; $administration = trim((string) ($filters['administration'] ?? '')); $administration = $administration === 'all' ? '' : $administration; +$securityLevel = trim((string) ($filters['security_level'] ?? '')); +$securityLevel = $securityLevel === 'all' ? '' : $securityLevel; $order = (string) ($filters['order'] ?? 'Customer_Name'); $dir = (string) ($filters['dir'] ?? 'asc'); $limit = (int) ($filters['limit'] ?? 10); @@ -113,6 +120,20 @@ if ($administration !== '') { })); } +// Security level filter +if ($securityLevel !== '' && $tenantId > 0) { + $securityLevelService = app(DomainSecurityLevelService::class); + $allDomainNos = array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $filtered); + $allDomainNos = array_values(array_filter($allDomainNos, static fn (string $n): bool => $n !== '')); + $filterLookup = $allDomainNos !== [] ? $securityLevelService->getAllLevelsForDomains($tenantId, $allDomainNos) : []; + $filtered = array_values(array_filter($filtered, static function (array $domain) use ($securityLevel, $filterLookup): bool { + $domainNo = trim((string) ($domain['No'] ?? '')); + $currentLevel = $filterLookup[$domainNo] ?? 'normal'; + + return $currentLevel === $securityLevel; + })); +} + // Sorting $total = count($filtered); @@ -127,6 +148,17 @@ usort($filtered, static function (array $a, array $b) use ($order, $dir): int { // Pagination $rows = array_slice($filtered, $offset, $limit); +// Security level enrichment for displayed rows +$securityLevelLookup = []; +if ($tenantId > 0) { + $securityLevelService = app(DomainSecurityLevelService::class); + $pagedDomainNos = array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $rows); + $pagedDomainNos = array_values(array_filter($pagedDomainNos, static fn (string $n): bool => $n !== '')); + if ($pagedDomainNos !== []) { + $securityLevelLookup = $securityLevelService->getAllDetailsForDomains($tenantId, $pagedDomainNos); + } +} + // Row preparation $stateVariantMap = [ 'Aktiv' => 'success', @@ -139,9 +171,13 @@ $preparedRows = []; foreach ($rows as $domain) { $customerNo = trim((string) ($domain['Customer_No'] ?? '')); $domainState = (string) ($domain['State'] ?? ''); + $domainNo = (string) ($domain['No'] ?? ''); + $details = $securityLevelLookup[$domainNo] ?? null; + $level = $details['level'] ?? 'normal'; + $note = $details['note'] ?? ''; $preparedRows[] = [ - 'No' => (string) ($domain['No'] ?? ''), + 'No' => $domainNo, 'Customer_No' => $customerNo, 'Customer_Name' => (string) ($domain['Customer_Name'] ?? ''), 'URL' => (string) ($domain['URL'] ?? ''), @@ -151,6 +187,9 @@ foreach ($rows as $domain) { 'contract_type' => (string) ($domain['contract_type'] ?? ''), 'contract_no' => (string) ($domain['contract_no'] ?? ''), 'debitor_url' => $customerNo !== '' ? $debitorBaseUrl . rawurlencode($customerNo) : '', + 'security_level' => $level, + 'security_level_variant' => DomainSecurityLevelService::getBadgeVariant($level), + 'security_level_note' => $note, ]; } diff --git a/modules/helpdesk/pages/helpdesk/domains/filter-schema.php b/modules/helpdesk/pages/helpdesk/domains/filter-schema.php index e9061ac..56f2c17 100644 --- a/modules/helpdesk/pages/helpdesk/domains/filter-schema.php +++ b/modules/helpdesk/pages/helpdesk/domains/filter-schema.php @@ -72,5 +72,21 @@ return gridFilterSchema([ ], 'label_attributes' => ['data-filter-optional' => true], ], + [ + 'key' => 'security_level', + 'type' => 'select', + 'label' => 'Security level', + 'input_id' => 'helpdesk-domains-security-level-filter', + 'default' => 'all', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => 'all', 'description' => 'All'], + ['id' => 'niedrig', 'description' => 'Low'], + ['id' => 'normal', 'description' => 'Normal'], + ['id' => 'hoch', 'description' => 'High'], + ['id' => 'kritisch', 'description' => 'Critical'], + ], + 'label_attributes' => ['data-filter-optional' => true], + ], ], ]); diff --git a/modules/helpdesk/pages/helpdesk/domains/index().php b/modules/helpdesk/pages/helpdesk/domains/index().php index 7ea3f09..73706f7 100644 --- a/modules/helpdesk/pages/helpdesk/domains/index().php +++ b/modules/helpdesk/pages/helpdesk/domains/index().php @@ -48,6 +48,12 @@ $filterChipMeta = [ 'default' => (string) ($schemaByKey['administration']['default'] ?? 'all'), 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['administration'] ?? [])), ], + 'security_level' => [ + 'label' => t('Security level'), + 'type' => 'select', + 'default' => (string) ($schemaByKey['security_level']['default'] ?? 'all'), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['security_level'] ?? [])), + ], ]; $settingsGateway = app(HelpdeskSettingsGateway::class); diff --git a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml index 36662ad..e92d9f6 100644 --- a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml @@ -32,10 +32,46 @@ require templatePath('partials/app-list-filters.phtml');
+ +
+
+

+ +
+

+
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+
+ diff --git a/modules/helpdesk/pages/helpdesk/domains/security-level-data().php b/modules/helpdesk/pages/helpdesk/domains/security-level-data().php new file mode 100644 index 0000000..62435fa --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/domains/security-level-data().php @@ -0,0 +1,99 @@ +all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); +$userId = (int) ($session['user']['id'] ?? 0); + +if ($tenantId <= 0) { + Router::json(['ok' => false, 'error' => 'No tenant context']); + + return; +} + +$domainNo = trim((string) ($request->body('domain_no', '') ?: $request->query('domain_no', ''))); + +if ($domainNo === '') { + Router::json(['ok' => false, 'error' => 'Missing domain number']); + + return; +} + +$service = app(DomainSecurityLevelService::class); + +if ($request->method() === 'POST') { + // CSRF check: Compare token from request body with session token + $csrfKey = Session::$csrfSessionKey; + $csrfSession = (string) ($session[$csrfKey] ?? ''); + $csrfRequest = (string) $request->body($csrfKey, ''); + + if ($csrfRequest === '' || $csrfRequest !== $csrfSession) { + // For AJAX requests, return JSON error + if ($request->wantsJson()) { + Router::json(['ok' => false, 'error' => 'Invalid CSRF token']); + return; + } + // For full-page POST, redirect with flash error + Flash::error(t('Invalid CSRF token'), 'helpdesk-domains', 'csrf'); + Router::redirect('helpdesk/domain/' . rawurlencode($domainNo)); + return; + } + + $level = trim((string) $request->body('security_level', '')); + + if ($level === '') { + Router::json(['ok' => false, 'error' => 'Missing security level']); + return; + } + + $allowed = DomainSecurityLevelService::getAllowedLevels(); + if (!in_array($level, $allowed, true)) { + Router::json(['ok' => false, 'error' => 'Invalid security level']); + return; + } + + $note = trim((string) $request->body('note', '')); + + $success = $service->setLevel($tenantId, $domainNo, $level, $userId, $note !== '' ? $note : null); + + if (!$success) { + Router::json(['ok' => false, 'error' => 'Failed to save security level']); + return; + } + + // For AJAX requests, return JSON + if ($request->wantsJson()) { + Router::json([ + 'ok' => true, + 'level' => $level, + 'badge_variant' => DomainSecurityLevelService::getBadgeVariant($level), + 'note' => $note, + ]); + return; + } + + // For full-page POST, redirect with flash success + Flash::success(t('Security level updated'), 'helpdesk-domains', 'security_level_saved'); + Router::redirect('helpdesk/domain/' . rawurlencode($domainNo)); + return; +} + +$details = $service->getDetails($tenantId, $domainNo); + +Router::json([ + 'ok' => true, + 'level' => $details['level'], + 'badge_variant' => DomainSecurityLevelService::getBadgeVariant($details['level']), + 'note' => $details['note'], +]); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DomainSecurityLevelServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DomainSecurityLevelServiceTest.php new file mode 100644 index 0000000..f2ec934 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DomainSecurityLevelServiceTest.php @@ -0,0 +1,141 @@ +createMock(DomainSecurityLevelRepository::class); + $repository->method('findByTenantAndDomainNo')->willReturn(null); + + $service = new DomainSecurityLevelService($repository); + + $this->assertSame('normal', $service->getLevel(self::TENANT_ID, 'DNS00001')); + } + + public function testGetLevelReturnsStoredValue(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->method('findByTenantAndDomainNo')->willReturn([ + 'security_level' => 'kritisch', + ]); + + $service = new DomainSecurityLevelService($repository); + + $this->assertSame('kritisch', $service->getLevel(self::TENANT_ID, 'DNS00001')); + } + + public function testGetLevelDefaultsOnInvalidStoredValue(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->method('findByTenantAndDomainNo')->willReturn([ + 'security_level' => 'ungueltig', + ]); + + $service = new DomainSecurityLevelService($repository); + + $this->assertSame('normal', $service->getLevel(self::TENANT_ID, 'DNS00001')); + } + + public function testGetLevelReturnsDefaultForEmptyDomainNo(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->expects($this->never())->method('findByTenantAndDomainNo'); + + $service = new DomainSecurityLevelService($repository); + + $this->assertSame('normal', $service->getLevel(self::TENANT_ID, '')); + } + + public function testSetLevelRejectsInvalidLevel(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->expects($this->never())->method('upsert'); + + $service = new DomainSecurityLevelService($repository); + + $this->assertFalse($service->setLevel(self::TENANT_ID, 'DNS00001', 'ungueltig', 1)); + } + + public function testSetLevelRejectsEmptyDomainNo(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->expects($this->never())->method('upsert'); + + $service = new DomainSecurityLevelService($repository); + + $this->assertFalse($service->setLevel(self::TENANT_ID, '', 'hoch', 1)); + } + + public function testSetLevelCallsUpsertWithCorrectParams(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->expects($this->once()) + ->method('upsert') + ->with(self::TENANT_ID, 'DNS00001', 'hoch', 42) + ->willReturn(true); + + $service = new DomainSecurityLevelService($repository); + + $this->assertTrue($service->setLevel(self::TENANT_ID, 'DNS00001', 'hoch', 42)); + } + + public function testSetLevelAllowsAllValidLevels(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->method('upsert')->willReturn(true); + + $service = new DomainSecurityLevelService($repository); + + foreach (['niedrig', 'normal', 'hoch', 'kritisch'] as $level) { + $this->assertTrue($service->setLevel(self::TENANT_ID, 'DNS00001', $level, 1)); + } + } + + public function testGetAllLevelsForDomainsReturnsEmptyForEmptyInput(): void + { + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->expects($this->never())->method('listLevelsByDomainNos'); + + $service = new DomainSecurityLevelService($repository); + + $this->assertSame([], $service->getAllLevelsForDomains(self::TENANT_ID, [])); + } + + public function testGetAllLevelsForDomainsDelegatesToRepository(): void + { + $expected = ['DNS00001' => 'hoch', 'DNS00002' => 'niedrig']; + $repository = $this->createMock(DomainSecurityLevelRepository::class); + $repository->method('listLevelsByDomainNos')->willReturn($expected); + + $service = new DomainSecurityLevelService($repository); + $result = $service->getAllLevelsForDomains(self::TENANT_ID, ['DNS00001', 'DNS00002']); + + $this->assertSame($expected, $result); + } + + public function testGetBadgeVariantMapping(): void + { + $this->assertSame('info', DomainSecurityLevelService::getBadgeVariant('niedrig')); + $this->assertSame('neutral', DomainSecurityLevelService::getBadgeVariant('normal')); + $this->assertSame('warning', DomainSecurityLevelService::getBadgeVariant('hoch')); + $this->assertSame('error', DomainSecurityLevelService::getBadgeVariant('kritisch')); + $this->assertSame('neutral', DomainSecurityLevelService::getBadgeVariant('unknown')); + } + + public function testGetAllowedLevelsReturnsFourLevels(): void + { + $levels = DomainSecurityLevelService::getAllowedLevels(); + + $this->assertCount(4, $levels); + $this->assertSame(['niedrig', 'normal', 'hoch', 'kritisch'], $levels); + } +} diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index dd4ee6f..4893de8 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -3109,4 +3109,18 @@ font-size: var(--text-xs); color: var(--app-muted, #6c757d); } + + td.gridjs-td .badge[role="button"] { + cursor: pointer; + transition: filter 0.12s ease; + } + + td.gridjs-td .badge[role="button"]:hover { + filter: brightness(0.92); + } + + td.gridjs-td .badge[role="button"]:focus { + outline: 2px solid var(--app-primary, #0d6efd); + outline-offset: 1px; + } } diff --git a/modules/helpdesk/web/js/helpdesk-domain-detail.js b/modules/helpdesk/web/js/helpdesk-domain-detail.js index 007a125..786aa19 100644 --- a/modules/helpdesk/web/js/helpdesk-domain-detail.js +++ b/modules/helpdesk/web/js/helpdesk-domain-detail.js @@ -8,7 +8,7 @@ * into innerHTML. This matches the pattern used in helpdesk-detail.js * and other helpdesk JS modules in this codebase. */ -import { getJson } from '/js/core/app-http.js'; +import { getJson, postForm } from '/js/core/app-http.js'; import { resolveHost } from '/js/core/app-dom.js'; import { renderEmptyState } from './helpdesk-empty-state.js'; @@ -54,6 +54,8 @@ function init(container) { if (!domainNo || !detailUrl) return; + bindSecurityLevelForm(); + const i18nEl = document.getElementById('helpdesk-domain-i18n'); const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {}; const t = (key) => i18n[key] || key; @@ -77,6 +79,7 @@ function init(container) { renderCustomerInfo(data.customer); renderContractInfo(data.contract); + renderSecurityLevel(data.security_level, data.security_level_variant); renderHandoversList(data.handovers || []); renderUpdatesList(data.updates || []); renderRelatedDomains(data.related_domains || []); @@ -159,6 +162,53 @@ function init(container) { ''; } + function renderSecurityLevel(level, variant) { + const badge = document.getElementById('domain-security-badge'); + const select = document.querySelector('[data-security-level-select]'); + if (!badge && !select) return; + + const levelLabels = { niedrig: t('Low'), normal: t('Normal'), hoch: t('High'), kritisch: t('Critical') }; + const label = levelLabels[level] ?? level; + + if (badge) { + badge.textContent = label; + badge.dataset.variant = variant || 'neutral'; + } + if (select) { + select.value = level || 'normal'; + } + } + + function bindSecurityLevelForm() { + const form = document.querySelector('[data-security-level-form]'); + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const select = form.querySelector('[data-security-level-select]'); + const domainNo = form.querySelector('input[name="domain_no"]')?.value || ''; + const level = select?.value || ''; + if (!domainNo || !level) return; + + const csrfKey = document.querySelector('[data-csrf-key]')?.dataset.csrfKey || '_csrf'; + const csrfToken = document.querySelector('[data-csrf-key]')?.dataset.csrfToken || ''; + const body = new URLSearchParams({ + domain_no: domainNo, + security_level: level, + [csrfKey]: csrfToken, + }); + + try { + const resp = await postForm('/helpdesk/domains/security-level-data', body); + if (resp?.ok) { + renderSecurityLevel(resp.level, resp.badge_variant); + } + } catch { + // silent fail — user can retry + } + }); + } + function renderHandoversList(handovers) { const el = document.getElementById('domain-handovers-list'); const countEl = document.getElementById('domain-handover-count'); diff --git a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js index edfd223..82d9711 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js @@ -1,5 +1,173 @@ import { createListPageModule } from '/js/core/app-list-page-module.js'; -import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js'; +import { escapeHtml, withCurrentListReturn, postAction } from '/js/pages/app-list-utils.js'; +import { postForm } from '/js/core/app-http.js'; + +const LEVEL_LABELS = { + niedrig: 'Low', + normal: 'Normal', + hoch: 'High', + kritisch: 'Critical', +}; + +function resolveDialog() { + const dialog = document.querySelector('[data-app-security-dialog]'); + if (!(dialog instanceof HTMLDialogElement)) { + return null; + } + return { + dialog, + domainEl: dialog.querySelector('#sec-dialog-domain'), + levelSelect: dialog.querySelector('#sec-dialog-level'), + noteTextarea: dialog.querySelector('#sec-dialog-note'), + csrfInput: dialog.querySelector('#sec-dialog-csrf'), + saveButton: dialog.querySelector('[data-sec-dialog-save]'), + cancelButton: dialog.querySelector('[data-sec-dialog-cancel]'), + closeButton: dialog.querySelector('[data-sec-dialog-close]'), + }; +} + +function openSecurityDialog(badge, securityLevelUrl, labels) { + const els = resolveDialog(); + if (!els) { + return; + } + + const domainNo = badge.dataset.domainNo || ''; + const currentLevel = badge.dataset.level || 'normal'; + const currentNote = badge.dataset.note || ''; + const domainName = badge.dataset.domainName || domainNo; + + els.domainEl.textContent = domainName; + els.levelSelect.value = currentLevel; + els.noteTextarea.value = currentNote; + els.saveButton.disabled = false; + + const activeElement = document.activeElement; + let saved = false; + + const cleanup = () => { + els.saveButton.removeEventListener('click', onSave); + els.cancelButton.removeEventListener('click', onCancel); + if (els.closeButton) { + els.closeButton.removeEventListener('click', onCancel); + } + els.dialog.removeEventListener('close', onClose); + els.dialog.removeEventListener('cancel', onCancelDialog); + els.dialog.removeEventListener('click', onBackdrop); + }; + + const closeDialog = () => { + saved = true; + cleanup(); + try { + if (els.dialog.open) { + els.dialog.close(); + } + } catch { /* no-op */ } + document.body.classList.remove('modal-is-open'); + if (activeElement && document.contains(activeElement)) { + window.requestAnimationFrame(() => { + activeElement.focus({ preventScroll: true }); + }); + } + }; + + const onSave = async () => { + const newLevel = els.levelSelect.value; + const note = els.noteTextarea.value.trim(); + const csrfKey = els.csrfInput?.name || 'csrf_token'; + const csrfToken = els.csrfInput?.value || ''; + + if (!csrfToken) { + console.error('[security-dialog] No CSRF token available'); + els.saveButton.disabled = false; + els.saveButton.removeAttribute('aria-busy'); + return; + } + + els.saveButton.disabled = true; + els.saveButton.setAttribute('aria-busy', 'true'); + + const body = new URLSearchParams({ + domain_no: domainNo, + security_level: newLevel, + note, + [csrfKey]: csrfToken, + }); + + try { + const resp = await postForm(securityLevelUrl, body); + if (resp?.ok) { + const levelLabel = labels?.[resp.level] ?? LEVEL_LABELS[resp.level] ?? resp.level; + const textNode = Array.from(badge.childNodes).find( + (n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '' + ); + if (textNode) { + textNode.textContent = levelLabel; + } + badge.dataset.variant = resp.badge_variant ?? 'neutral'; + badge.dataset.level = resp.level ?? newLevel; + badge.dataset.note = resp.note ?? note; + closeDialog(); + } else { + console.warn('[security-dialog] Save returned ok=false:', resp); + els.saveButton.disabled = false; + els.saveButton.removeAttribute('aria-busy'); + } + } catch (error) { + console.error('[security-dialog] Save failed:', error); + els.saveButton.disabled = false; + els.saveButton.removeAttribute('aria-busy'); + } + }; + + const onCancel = (event) => { + event.preventDefault(); + closeDialog(); + }; + + const onCancelDialog = (event) => { + event.preventDefault(); + closeDialog(); + }; + + const onClose = () => { + if (!saved) { + saved = true; + cleanup(); + document.body.classList.remove('modal-is-open'); + } + }; + + const onBackdrop = (event) => { + if (event.target === els.dialog) { + closeDialog(); + } + }; + + els.saveButton.addEventListener('click', onSave); + els.cancelButton.addEventListener('click', onCancel); + if (els.closeButton) { + els.closeButton.addEventListener('click', onCancel); + } + els.dialog.addEventListener('cancel', onCancelDialog); + els.dialog.addEventListener('close', onClose); + els.dialog.addEventListener('click', onBackdrop); + + document.body.classList.add('modal-is-open'); + + try { + els.dialog.showModal(); + } catch { + cleanup(); + document.body.classList.remove('modal-is-open'); + return; + } + + window.requestAnimationFrame(() => { + els.levelSelect.focus(); + }); +} createListPageModule({ configId: 'helpdesk-domains', @@ -9,8 +177,34 @@ createListPageModule({ setup: ({ config, gridjs, appBase, initListPage }) => { const labels = config.labels || {}; const domainBaseUrl = config.domainBaseUrl || ''; + const securityLevelUrl = (config.securityLevelDataUrl || '').trim(); const domainUrlIndex = 7; + const gridContainer = document.querySelector('#helpdesk-domains-grid'); + + gridContainer.addEventListener('click', (event) => { + const badge = event.target.closest('td .badge[data-domain-no][role="button"]'); + if (!badge) { + return; + } + event.preventDefault(); + event.stopPropagation(); + openSecurityDialog(badge, securityLevelUrl, labels); + }); + + gridContainer.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + const badge = event.target.closest('.badge[data-domain-no][role="button"]'); + if (!badge) { + return; + } + event.preventDefault(); + event.stopPropagation(); + openSecurityDialog(badge, securityLevelUrl, labels); + }); + const { gridConfig } = initListPage({ grid: { gridjs, @@ -56,10 +250,24 @@ createListPageModule({ }, }, { name: labels.administration || 'Administration', sort: true }, + { + name: labels.securityLevel || 'Security level', + sort: true, + formatter: (cell) => { + const level = cell?.level || 'normal'; + const variant = cell?.variant || 'neutral'; + const domainNo = cell?.domainNo || ''; + const note = cell?.note || ''; + const domainName = cell?.domainName || domainNo; + const levelLabel = labels?.[level] ?? LEVEL_LABELS[level] ?? level; + const noteAttr = note ? ` data-note="${escapeHtml(note)}"` : ''; + return gridjs.html(`${escapeHtml(levelLabel)}`); + }, + }, { name: 'debitor_url', hidden: true }, { name: 'domain_detail_url', hidden: true }, ], - sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null], + sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', 'security_level', null, null], paginationLimit: 10, language: config.gridLang || {}, mapData: (data) => (data.data || []).map((row) => { @@ -73,6 +281,7 @@ createListPageModule({ row.contract_type || '', { state: row.State || '', variant: row.state_variant || 'neutral' }, row.Administration || '', + { level: row.security_level || 'normal', variant: row.security_level_variant || 'neutral', domainNo: row.No || '', note: row.security_level_note || '', domainName: row.Customer_Name || row.No || '' }, row.debitor_url || '', domainDetailUrl, ]; @@ -83,6 +292,7 @@ createListPageModule({ rowInteraction: { linkColumn: 0 }, rowDblClick: { getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '', + exclude: '.badge[role="button"], .grid-actions, button, a, input, select, textarea', }, }, filters: { diff --git a/web/js/core/app-http.js b/web/js/core/app-http.js index 113e48f..6994e32 100644 --- a/web/js/core/app-http.js +++ b/web/js/core/app-http.js @@ -3,7 +3,7 @@ import { telemetry } from './app-telemetry.js'; const JSON_ACCEPT = 'application/json'; const DEFAULT_HEADERS = Object.freeze({ 'Accept': JSON_ACCEPT, - 'X-Requested-With': 'fetch', + 'X-Requested-With': 'XMLHttpRequest', }); const toObject = (value) => {