fix(helpdesk): repair security-level persistence, enrichment, and list UX
Saving security levels appeared broken in both the domain detail view and the domains grid. Root causes: - Repository never unwrapped MintyPHP DB rows (nested by table name), so reads always returned defaults even though writes succeeded. - Batch queries bound tenant_id last while SQL expected it first, so list/detail enrichment for the grid matched nothing. - Detail page AJAX submit looked up CSRF via a non-existent data attribute and dropped the note field entirely. - Endpoint used a hand-rolled CSRF check instead of Session::checkCsrfToken(). Fixes: - DomainSecurityLevelRepository: use RepositoryArrayHelper::unwrap(List), swap param order in listLevelsByDomainNos / listDetailsByDomainNos, centralize table name in a const. - security-level-data endpoint: canonical Session::checkCsrfToken(), unified JSON/PRG error path with proper HTTP status codes and flash. - Detail page: drop broken AJAX hijack, rely on native form POST + PRG (GR-CORE-012). - Grid list: refetch via gridRef.grid.forceRender() after dialog save instead of DOM-only mutation, so Grid.js internal state stays in sync. - Add dedicated Note column next to the badge with ellipsis + title tooltip. - Replace console.* in the dialog with showAsyncFlash for user-visible errors; add i18n keys (de/en) for all error messages. - Drop unused postAction import and TENANT_ID_OTHER test constant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3123,4 +3123,14 @@
|
||||
outline: 2px solid var(--app-primary, #0d6efd);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
td.gridjs-td .app-cell-note {
|
||||
display: inline-block;
|
||||
max-width: 24ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, postForm } from '/js/core/app-http.js';
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import { renderEmptyState } from './helpdesk-empty-state.js';
|
||||
|
||||
@@ -54,8 +54,6 @@ 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;
|
||||
@@ -179,36 +177,6 @@ function init(container) {
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +1,7 @@
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, withCurrentListReturn, postAction } from '/js/pages/app-list-utils.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
import { postForm } from '/js/core/app-http.js';
|
||||
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
||||
|
||||
const LEVEL_LABELS = {
|
||||
niedrig: 'Low',
|
||||
@@ -26,7 +27,7 @@ function resolveDialog() {
|
||||
};
|
||||
}
|
||||
|
||||
function openSecurityDialog(badge, securityLevelUrl, labels) {
|
||||
function openSecurityDialog(badge, securityLevelUrl, labels, onSaved) {
|
||||
const els = resolveDialog();
|
||||
if (!els) {
|
||||
return;
|
||||
@@ -78,10 +79,10 @@ function openSecurityDialog(badge, securityLevelUrl, labels) {
|
||||
const csrfKey = els.csrfInput?.name || 'csrf_token';
|
||||
const csrfToken = els.csrfInput?.value || '';
|
||||
|
||||
const errorLabel = labels?.saveError || 'Save failed';
|
||||
|
||||
if (!csrfToken) {
|
||||
console.error('[security-dialog] No CSRF token available');
|
||||
els.saveButton.disabled = false;
|
||||
els.saveButton.removeAttribute('aria-busy');
|
||||
showAsyncFlash('error', errorLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,24 +99,16 @@ function openSecurityDialog(badge, securityLevelUrl, labels) {
|
||||
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');
|
||||
if (typeof onSaved === 'function') {
|
||||
onSaved();
|
||||
}
|
||||
return;
|
||||
}
|
||||
showAsyncFlash('error', resp?.error || errorLabel);
|
||||
} catch (error) {
|
||||
console.error('[security-dialog] Save failed:', error);
|
||||
showAsyncFlash('error', error?.message || errorLabel);
|
||||
} finally {
|
||||
els.saveButton.disabled = false;
|
||||
els.saveButton.removeAttribute('aria-busy');
|
||||
}
|
||||
@@ -178,9 +171,13 @@ createListPageModule({
|
||||
const labels = config.labels || {};
|
||||
const domainBaseUrl = config.domainBaseUrl || '';
|
||||
const securityLevelUrl = (config.securityLevelDataUrl || '').trim();
|
||||
const domainUrlIndex = 7;
|
||||
const domainUrlIndex = 8;
|
||||
|
||||
const gridContainer = document.querySelector('#helpdesk-domains-grid');
|
||||
const gridRef = { grid: null };
|
||||
const refreshGrid = () => {
|
||||
gridRef.grid?.forceRender();
|
||||
};
|
||||
|
||||
gridContainer.addEventListener('click', (event) => {
|
||||
const badge = event.target.closest('td .badge[data-domain-no][role="button"]');
|
||||
@@ -189,7 +186,7 @@ createListPageModule({
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSecurityDialog(badge, securityLevelUrl, labels);
|
||||
openSecurityDialog(badge, securityLevelUrl, labels, refreshGrid);
|
||||
});
|
||||
|
||||
gridContainer.addEventListener('keydown', (event) => {
|
||||
@@ -202,7 +199,7 @@ createListPageModule({
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSecurityDialog(badge, securityLevelUrl, labels);
|
||||
openSecurityDialog(badge, securityLevelUrl, labels, refreshGrid);
|
||||
});
|
||||
|
||||
const { gridConfig } = initListPage({
|
||||
@@ -264,10 +261,22 @@ createListPageModule({
|
||||
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: labels.note || 'Note',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const note = String(cell || '').trim();
|
||||
if (note === '') {
|
||||
return gridjs.html('<span class="text-muted">—</span>');
|
||||
}
|
||||
const escaped = escapeHtml(note);
|
||||
return gridjs.html(`<span class="app-cell-note" title="${escaped}">${escaped}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: 'debitor_url', hidden: true },
|
||||
{ name: 'domain_detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', 'security_level', null, null],
|
||||
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', 'security_level', 'security_level_note', null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => {
|
||||
@@ -282,6 +291,7 @@ createListPageModule({
|
||||
{ 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.security_level_note || '',
|
||||
row.debitor_url || '',
|
||||
domainDetailUrl,
|
||||
];
|
||||
@@ -302,6 +312,8 @@ createListPageModule({
|
||||
},
|
||||
}) || {};
|
||||
|
||||
gridRef.grid = gridConfig?.grid || null;
|
||||
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user