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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user