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

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