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:
2026-04-21 13:43:16 +02:00
parent c3de7d4238
commit 6ac685084d
23 changed files with 1017 additions and 19 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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());

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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`;

View File

@@ -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'],

View File

@@ -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'));

View File

@@ -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',
@@ -101,17 +109,50 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
</div>
</details>
<!-- Contract Info -->
<details class="app-details-card" id="domain-contract-section" open hidden>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Contract information')); ?></span>
</summary>
<div class="app-details-card-container">
<div id="domain-contract-info">
<p class="text-muted"><?php e(t('Loading...')); ?></p>
</div>
<!-- Contract Info -->
<details class="app-details-card" id="domain-contract-section" open hidden>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Contract information')); ?></span>
</summary>
<div class="app-details-card-container">
<div id="domain-contract-info">
<p class="text-muted"><?php e(t('Loading...')); ?></p>
</div>
</details>
</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>
@@ -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>

View File

@@ -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);

View File

@@ -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,
];
}

View File

@@ -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],
],
],
]);

View File

@@ -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);

View File

@@ -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>

View File

@@ -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'],
]);

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -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: {