feat(cms): confirm-before-logout and branded login page
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions

- logout asks for confirmation, then invalidates the server-side session
  and navigates to a public /login page (logo, form, error messages)
- POST /login validates credentials (timing-safe) and issues an HttpOnly
  SameSite=Strict session cookie (8h, Secure behind HTTPS); Basic Auth
  stays valid in parallel for curl/API use
- unauthenticated browser navigations redirect to /login; non-browser
  requests keep the 401 challenge
- failed form logins share the auth rate-limit budget with Basic attempts
- save/publish redirect to /login when the session expired
- refactor: templates and browser script extracted to scripts/cms-pages.js
  and scripts/cms-editor-client.js, session logic to scripts/cms-session.js
  (content-editor.js back under the 400-line limit)
- user guide updated (login page, confirmation, 8h session)

Closes MITHOME-58
This commit is contained in:
Do Siki
2026-08-18 14:01:42 +02:00
parent 9880b5b1d5
commit 5fe36584dd
6 changed files with 845 additions and 468 deletions
+305
View File
@@ -0,0 +1,305 @@
// Browser-side script of the Content Editor editor page.
// Inlined into the HTML template at render time by content-editor.js.
// Test coverage: scripts/test-content-editor-serializer.js runs this exact code.
// ── Render ──────────────────────────────────────────────────────────────────
function render(obj, container) {
container.innerHTML = '';
renderObject(obj, container, '');
}
function renderObject(obj, container, prefix) {
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? prefix + '.' + key : key;
if (Array.isArray(val)) {
renderArray(key, val, container, path);
} else if (typeof val === 'object' && val !== null) {
renderObject(val, container, path);
} else {
renderPrimitive(path, val, container);
}
}
}
function renderPrimitive(path, val, container) {
const isLong = String(val).length > 80 || String(val).includes('<');
const div = document.createElement('div');
div.className = 'field';
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = `<input type="checkbox" data-path="${path}" data-type="boolean" ${val ? 'checked' : ''}>`;
} else if (type === 'number') {
control = `<input type="number" data-path="${path}" data-type="number" value="${esc(val)}">`;
} else {
control = isLong
? `<textarea data-path="${path}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${path}" data-type="${type}" value="${esc(val ?? '')}">`;
}
div.innerHTML = `
<label>${path}</label>
${control}
`;
container.appendChild(div);
}
function renderArray(key, arr, container, path) {
const section = document.createElement('div');
section.className = 'array-section';
section.dataset.arrayPath = path;
const label = document.createElement('div');
label.className = 'array-label';
label.textContent = path;
section.appendChild(label);
const items = document.createElement('div');
items.className = 'array-items';
items.dataset.arrayItems = path;
section.appendChild(items);
arr.forEach((item, i) => {
if (typeof item === 'object' && item !== null) {
items.appendChild(makeObjCard(item, i, path));
} else {
items.appendChild(makeStrItem(item, i, path));
}
});
// Template for adding new items
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
const isObj = typeof sample === 'object' && sample !== null;
const addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.textContent = ' Új elem hozzáadása';
addBtn.onclick = () => {
const idx = items.children.length;
if (isObj) {
const blank = blankLike(sample);
items.appendChild(makeObjCard(blank, idx, path));
} else {
items.appendChild(makeStrItem('', idx, path));
}
reindexItems(items);
};
section.appendChild(addBtn);
container.appendChild(section);
}
function blankLike(value) {
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
}
if (typeof value === 'boolean') return false;
if (typeof value === 'number') return 0;
return '';
}
function makeStrItem(val, idx, path) {
const wrap = document.createElement('div');
wrap.className = 'str-item';
const type = val === null ? 'null' : typeof val;
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
ta.dataset.path = path + '[' + idx + ']';
ta.dataset.type = type;
if (type === 'boolean') {
ta.type = 'checkbox';
ta.checked = val;
} else {
ta.value = val ?? '';
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
}
const del = document.createElement('button');
del.className = 'btn-del';
del.textContent = '❌';
del.title = 'Törlés';
del.onclick = () => { wrap.remove(); reindexItems(wrap.closest('.array-items')); };
wrap.appendChild(ta);
wrap.appendChild(del);
return wrap;
}
function makeObjCard(obj, idx, path) {
const card = document.createElement('div');
card.className = 'obj-card';
const hdr = document.createElement('div');
hdr.className = 'card-header';
hdr.textContent = path + '[' + idx + ']';
card.appendChild(hdr);
renderObject(obj, card, path + '[' + idx + ']');
const del = document.createElement('button');
del.className = 'btn-del-card';
del.textContent = '❌ Törlés';
del.onclick = () => { card.remove(); reindexItems(card.closest('.array-items')); };
card.appendChild(del);
return card;
}
function reindexItems(itemsEl) {
if (!itemsEl) return;
const path = itemsEl.dataset.arrayItems;
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
// services[1].specs.items rewrites the OUTER services index and scatters paths
// across services[0..n], producing sparse arrays and schema errors).
const prefix = path + '[';
Array.from(itemsEl.children).forEach((child, i) => {
child.querySelectorAll('[data-path]').forEach(el => {
const old = el.dataset.path;
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
const rest = old.slice(prefix.length);
const bracketEnd = rest.indexOf(']');
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
el.dataset.path = prefix + i + suffix;
});
// Update card header
const hdr = child.querySelector('.card-header');
if (hdr) hdr.textContent = path + '[' + i + ']';
});
}
// ── Collect & Save ───────────────────────────────────────────────────────────
function collect() {
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
// Wipe all arrays so we rebuild them from DOM
clearArrays(result);
document.querySelectorAll('[data-path]').forEach(el => {
setPath(result, el.dataset.path, readValue(el));
});
return result;
}
function readValue(el) {
switch (el.dataset.type) {
case 'boolean': return el.checked;
case 'number': return Number(el.value);
case 'null': return el.value === '' ? null : el.value;
default: return el.value;
}
}
function clearArrays(obj) {
for (const k of Object.keys(obj)) {
if (Array.isArray(obj[k])) obj[k] = [];
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
}
}
function setPath(obj, path, value) {
const parts = parsePath(path);
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (cur[part] === undefined || cur[part] === null) {
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function parsePath(path) {
const parts = [];
let token = '';
let inIndex = false;
for (const char of path) {
if (char === '.') {
if (!inIndex && token) parts.push(token);
token = '';
} else if (char === '[') {
if (token) parts.push(token);
token = '';
inIndex = true;
} else if (char === ']') {
parts.push(Number(token));
token = '';
inIndex = false;
} else {
token += char;
}
}
if (token) parts.push(token);
return parts;
}
async function save() {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
const status = document.getElementById('saveStatus');
if (json.ok) {
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
}
status.style.display = 'inline';
setTimeout(() => status.style.display = 'none', 3000);
}
async function publish() {
const btn = document.getElementById('publishBtn');
const status = document.getElementById('saveStatus');
// Save first
await save();
btn.textContent = '⏳ Élesítés folyamatban...';
btn.disabled = true;
try {
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
status.style.color = '#f87171';
}
} catch (e) {
status.textContent = '❌ Hálózati hiba';
status.style.color = '#f87171';
}
btn.textContent = '🚀 Publikálás & Élesítés';
btn.disabled = false;
status.style.display = 'inline';
setTimeout(() => status.style.display = 'none', 5000);
}
async function logout() {
if (!confirm('Biztosan ki szeretnél lépni?')) return;
try {
// Invalidates the server-side session cookie (Basic Auth cache is not
// affected — the login page is public, no 401-overwrite is needed).
await fetch('/logout', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
} catch (e) { /* network error — continue to the login page */ }
location.href = '/login';
}
function esc(v) {
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Boot
render(DATA, document.getElementById('editor'));
// Auto-dismiss toast
const toast = document.querySelector('.toast');
if (toast) setTimeout(() => toast.remove(), 3500);