- Repository migration 0005: insight.SupportCases. The web UI has only db_datareader, so INSERT on this one table is granted to public. - Web: /support (menu "Support") lists the customer's cases and, with Hub sync on and a Professional/Enterprise license, a form (title, description, contact; contact prefilled from the Windows user). /support?fund=<id> prefills from a finding: instance, rule, severity, explanation and non-sensitive measurements. - "Opret supportsag" on findings: right-click a finding link or a finding's heading (support-menu.js, a static file within the CSP; Shift+F10/context key, Escape closes), and a visible button on the finding page that works without JavaScript. Only when support is available. - Agent: sends pending requests to POST /api/v1/cases, stores the case number, backs off on failures and gives up on 400/401/403/422; fetches the customer's cases every 5 minutes (at once after sending). - Docs: support.md (also on the community site), databehandling.md (contact details are sent to the hub). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
78 lines
2.9 KiB
JavaScript
78 lines
2.9 KiB
JavaScript
// "Opret supportsag" in a context menu on findings: right-click a link to /fund/<id> (any finding list) or the
|
|
// heading of a finding's own page. Keyboard: Shift+F10 or the context menu key on a focused finding; Escape closes.
|
|
// Only when the layout says support is available (data-support-available). Without JavaScript the finding page has a
|
|
// visible "Opret supportsag" button, so nothing depends on this file.
|
|
(() => {
|
|
const available = () => document.querySelector('[data-support-available]') !== null;
|
|
let menu = null;
|
|
let opener = null;
|
|
|
|
const findingId = (element) => {
|
|
const marked = element.closest('[data-finding-id]');
|
|
if (marked) return marked.dataset.findingId;
|
|
const link = element.closest('a[href]');
|
|
const match = link && /^\/fund\/(\d+)$/.exec(new URL(link.href, document.baseURI).pathname);
|
|
return match ? match[1] : null;
|
|
};
|
|
|
|
const close = (restoreFocus) => {
|
|
if (!menu) return;
|
|
menu.remove();
|
|
menu = null;
|
|
if (restoreFocus && opener) opener.focus();
|
|
opener = null;
|
|
};
|
|
|
|
const open = (id, x, y, target) => {
|
|
close(false);
|
|
opener = target.closest('a[href], [tabindex]') || target;
|
|
menu = document.createElement('div');
|
|
menu.className = 'context-menu';
|
|
menu.setAttribute('role', 'menu');
|
|
menu.setAttribute('aria-label', 'Fund');
|
|
const item = document.createElement('a');
|
|
item.setAttribute('role', 'menuitem');
|
|
item.href = `/support?fund=${encodeURIComponent(id)}`;
|
|
item.textContent = 'Opret supportsag';
|
|
menu.appendChild(item);
|
|
document.body.appendChild(menu);
|
|
|
|
// Keep it inside the window. Setting style through the CSSOM is allowed by the page's Content-Security-Policy.
|
|
const width = menu.offsetWidth;
|
|
const height = menu.offsetHeight;
|
|
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4)) + window.scrollX}px`;
|
|
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4)) + window.scrollY}px`;
|
|
item.focus();
|
|
};
|
|
|
|
document.addEventListener('contextmenu', (event) => {
|
|
if (!available()) return;
|
|
const id = findingId(event.target);
|
|
if (!id) return;
|
|
event.preventDefault();
|
|
// From the keyboard there is no pointer position: place the menu under the focused element.
|
|
let { clientX: x, clientY: y } = event;
|
|
if (x === 0 && y === 0) {
|
|
const box = event.target.getBoundingClientRect();
|
|
x = box.left;
|
|
y = box.bottom + 4;
|
|
}
|
|
open(id, x, y, event.target);
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (!menu) return;
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
close(true);
|
|
} else if (event.key === 'Tab') {
|
|
close(false);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mousedown', (event) => {
|
|
if (menu && !menu.contains(event.target)) close(false);
|
|
});
|
|
window.addEventListener('scroll', () => close(false), { passive: true });
|
|
window.addEventListener('resize', () => close(false));
|
|
})();
|