Feature: Security Level mit Notiz für Domains
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
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class DomainSecurityLevelRepository
|
||||
{
|
||||
private const ALLOWED_LEVELS = ['niedrig', 'normal', 'hoch', 'kritisch'];
|
||||
|
||||
public function findByTenantAndDomainNo(int $tenantId, string $domainNo): ?array
|
||||
{
|
||||
if ($domainNo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'SELECT * FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no = ? LIMIT 1',
|
||||
(string) $tenantId,
|
||||
$domainNo
|
||||
);
|
||||
|
||||
if (!is_array($row) || $row === []) {
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $domainNos
|
||||
* @return array<string, string> 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<string> $domainNos
|
||||
* @return array<string, array{level: string, note: string}> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\DomainSecurityLevelRepository;
|
||||
|
||||
class DomainSecurityLevelService
|
||||
{
|
||||
private const ALLOWED_LEVELS = ['niedrig', 'normal', 'hoch', 'kritisch'];
|
||||
|
||||
private const BADGE_VARIANTS = [
|
||||
'niedrig' => '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<string> $domainNos
|
||||
* @return array<string, array{level: string, note: string}>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
/**
|
||||
* @api Called from AJAX endpoint for validation
|
||||
*/
|
||||
public static function getAllowedLevels(): array
|
||||
{
|
||||
return self::ALLOWED_LEVELS;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
@@ -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'],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
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\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
@@ -47,6 +49,14 @@ $customerName = (string) ($domain['Customer_Name'] ?? '');
|
||||
$domainState = (string) ($domain['State'] ?? '');
|
||||
$domainAdministration = (string) ($domain['Administration'] ?? '');
|
||||
|
||||
$session = app(SessionStoreInterface::class)->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'));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Session;
|
||||
|
||||
/**
|
||||
* @var string $domainNo
|
||||
* @var array $domain
|
||||
@@ -11,6 +13,9 @@
|
||||
* @var string $domainState
|
||||
* @var string $domainAdministration
|
||||
* @var string $pageTitle
|
||||
* @var string $securityLevel
|
||||
* @var string $securityLevelVariant
|
||||
* @var string $securityLevelNote
|
||||
*/
|
||||
|
||||
$domainNo = $domainNo ?? '';
|
||||
@@ -23,6 +28,9 @@ $customerName = $customerName ?? '';
|
||||
$domainState = $domainState ?? '';
|
||||
$domainAdministration = $domainAdministration ?? '';
|
||||
$pageTitle = $pageTitle ?? $domainNo;
|
||||
$securityLevel = $securityLevel ?? 'normal';
|
||||
$securityLevelVariant = $securityLevelVariant ?? 'neutral';
|
||||
$securityLevelNote = $securityLevelNote ?? '';
|
||||
|
||||
$stateVariantMap = [
|
||||
'Aktiv' => 'success',
|
||||
@@ -113,6 +121,39 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Security Level -->
|
||||
<details class="app-details-card" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Security level')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<div id="domain-security-level-form">
|
||||
<form method="POST" action="<?php e(lurl('helpdesk/domains/security-level-data')); ?>" data-security-level-form>
|
||||
<input type="hidden" name="domain_no" value="<?php e($domainNo); ?>">
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
<div class="app-form-grid">
|
||||
<div class="app-form-group">
|
||||
<label for="domain-security-level-select"><?php e(t('Security level')); ?></label>
|
||||
<select name="security_level" id="domain-security-level-select" data-security-level-select>
|
||||
<option value="niedrig" <?php e($securityLevel === 'niedrig' ? 'selected' : ''); ?>><?php e(t('Low')); ?></option>
|
||||
<option value="normal" <?php e($securityLevel === 'normal' ? 'selected' : ''); ?>><?php e(t('Normal')); ?></option>
|
||||
<option value="hoch" <?php e($securityLevel === 'hoch' ? 'selected' : ''); ?>><?php e(t('High')); ?></option>
|
||||
<option value="kritisch" <?php e($securityLevel === 'kritisch' ? 'selected' : ''); ?>><?php e(t('Critical')); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="app-form-group">
|
||||
<label for="domain-security-level-note"><?php e(t('Note')); ?></label>
|
||||
<textarea name="note" id="domain-security-level-note" rows="3" placeholder="<?php e(t('Optional note...')); ?>"><?php e($securityLevelNote); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-form-actions">
|
||||
<button type="submit" class="secondary outline small"><?php e(t('Save')); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Handovers (full width) -->
|
||||
@@ -166,6 +207,7 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
|
||||
<?php if ($domainAdministration !== ''): ?>
|
||||
<span class="badge" data-variant="neutral"><?php e($domainAdministration); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="badge" data-variant="<?php e($securityLevelVariant); ?>" id="domain-security-badge"><?php e(t(ucfirst($securityLevel))); ?></span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$session = app(SessionStoreInterface::class)->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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -32,10 +32,46 @@ require templatePath('partials/app-list-filters.phtml');
|
||||
<div id="helpdesk-domains-grid"></div>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
data-app-security-dialog
|
||||
aria-labelledby="sec-dialog-title"
|
||||
aria-describedby="sec-dialog-domain"
|
||||
>
|
||||
<article class="app-confirm-dialog app-dialog-sm">
|
||||
<header>
|
||||
<h2 id="sec-dialog-title"><?php e(t('Security level')); ?></h2>
|
||||
<button type="button" class="close" data-sec-dialog-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
|
||||
</header>
|
||||
<p id="sec-dialog-domain" class="muted"></p>
|
||||
<div class="app-form-grid">
|
||||
<?php $csrfKey = \MintyPHP\Session::$csrfSessionKey; $csrfToken = (string) ($_SESSION[$csrfKey] ?? ''); ?>
|
||||
<input type="hidden" name="<?php e($csrfKey); ?>" id="sec-dialog-csrf" value="<?php e($csrfToken); ?>">
|
||||
<div class="app-form-group">
|
||||
<label for="sec-dialog-level"><?php e(t('Security level')); ?></label>
|
||||
<select id="sec-dialog-level" name="security_level">
|
||||
<option value="niedrig"><?php e(t('Low')); ?></option>
|
||||
<option value="normal"><?php e(t('Normal')); ?></option>
|
||||
<option value="hoch"><?php e(t('High')); ?></option>
|
||||
<option value="kritisch"><?php e(t('Critical')); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="app-form-group">
|
||||
<label for="sec-dialog-note"><?php e(t('Note')); ?></label>
|
||||
<textarea id="sec-dialog-note" name="note" rows="3" placeholder="<?php e(t('Optional note...')); ?>"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="secondary outline" data-sec-dialog-cancel><?php e(t('Cancel')); ?></button>
|
||||
<button type="button" class="primary" data-sec-dialog-save><?php e(t('Save')); ?></button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="application/json" id="page-config-helpdesk-domains"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('helpdesk/domains-data'),
|
||||
'domainBaseUrl' => lurl('helpdesk/domain/'),
|
||||
'securityLevelDataUrl' => lurl('helpdesk/domains/security-level-data'),
|
||||
'gridLang' => gridLang(),
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
@@ -47,6 +83,7 @@ require templatePath('partials/app-list-filters.phtml');
|
||||
'state' => t('State'),
|
||||
'administration' => t('Administration'),
|
||||
'contractType' => t('Contract type'),
|
||||
'securityLevel' => t('Security level'),
|
||||
],
|
||||
]); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/pages/helpdesk-domains-index.js')); ?>"></script>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
$session = app(SessionStoreInterface::class)->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'],
|
||||
]);
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\DomainSecurityLevelRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DomainSecurityLevelServiceTest extends TestCase
|
||||
{
|
||||
private const TENANT_ID = 1;
|
||||
private const TENANT_ID_OTHER = 2;
|
||||
|
||||
public function testGetLevelReturnsDefaultForUnknownDomain(): void
|
||||
{
|
||||
$repository = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
'</dl>';
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -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(`<span class="badge" data-variant="${escapeHtml(variant)}" data-domain-no="${escapeHtml(domainNo)}" data-level="${escapeHtml(level)}" data-domain-name="${escapeHtml(domainName)}" role="button" tabindex="0"${noteAttr}>${escapeHtml(levelLabel)}</span>`);
|
||||
},
|
||||
},
|
||||
{ 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: {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user