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
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:
+84
-466
@@ -46,468 +46,12 @@ const FILE_LABELS = {
|
|||||||
hasznalatiFeltetelek: '⚖️ ÁSZF',
|
hasznalatiFeltetelek: '⚖️ ÁSZF',
|
||||||
};
|
};
|
||||||
|
|
||||||
const HTML = (activeFile, jsonData, message, csrfToken) => `<!DOCTYPE html>
|
const { HTML, GUIDE_PAGE, LOGIN_PAGE } = require('./scripts/cms-pages');
|
||||||
<html lang="hu">
|
const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session');
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${CMS_DEPLOY_ENV === 'staging' ? 'STAGING — ' : ''}mozdIT Content Editor</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
|
|
||||||
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
|
||||||
|
|
||||||
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; }
|
// Browser script is kept in its own file and inlined into the HTML template at render time.
|
||||||
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8');
|
||||||
header span { color: #64748b; font-size: 13px; }
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 2px; padding: 14px 32px 0; border-bottom: 1px solid #2d3748; background: #13192a; }
|
|
||||||
.tab { text-decoration: none; color: #94a3b8; padding: 9px 16px; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 500; transition: all .2s; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
|
|
||||||
.tab:hover { color: #e2e8f0; background: #1e2535; }
|
|
||||||
.tab.active { color: #60a5fa; background: #0f1117; border-color: #2d3748; }
|
|
||||||
|
|
||||||
.page { max-width: 860px; margin: 28px auto 120px; padding: 0 24px; }
|
|
||||||
.hint { color: #475569; font-size: 12px; margin-bottom: 20px; }
|
|
||||||
|
|
||||||
/* Primitive field */
|
|
||||||
.field { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 16px; transition: border-color .2s; margin-bottom: 10px; }
|
|
||||||
.field:focus-within { border-color: #60a5fa; }
|
|
||||||
.field label { display: block; font-size: 11px; font-weight: 600; color: #60a5fa; text-transform: uppercase; letter-spacing:.05em; margin-bottom: 7px; font-family: monospace; }
|
|
||||||
.field input, .field textarea { width: 100%; background: transparent; border: none; outline: none; color: #e2e8f0; font-size: 14px; line-height: 1.6; resize: vertical; font-family: inherit; }
|
|
||||||
.field textarea { min-height: 52px; }
|
|
||||||
|
|
||||||
/* Array section */
|
|
||||||
.array-section { margin-bottom: 20px; }
|
|
||||||
.array-label { font-size: 12px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing:.06em; font-family: monospace; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
|
||||||
.array-label::after { content:''; flex: 1; height: 1px; background: #2d3748; }
|
|
||||||
|
|
||||||
.array-items { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
|
|
||||||
/* Simple string array item */
|
|
||||||
.str-item { display: flex; gap: 8px; align-items: flex-start; }
|
|
||||||
.str-item textarea { flex: 1; background: #1a2035; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; color: #e2e8f0; font-size: 14px; font-family: inherit; outline: none; resize: vertical; min-height: 44px; transition: border-color .2s; }
|
|
||||||
.str-item textarea:focus { border-color: #60a5fa; }
|
|
||||||
|
|
||||||
/* Object array item (card) */
|
|
||||||
.obj-card { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; position: relative; }
|
|
||||||
.obj-card .card-header { font-size: 11px; color: #64748b; font-family: monospace; margin-bottom: 10px; }
|
|
||||||
.obj-card .inner-field { margin-bottom: 8px; }
|
|
||||||
.obj-card .inner-field:last-child { margin-bottom: 0; }
|
|
||||||
.obj-card .inner-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing:.05em; font-family: monospace; margin-bottom: 4px; }
|
|
||||||
.obj-card input, .obj-card textarea { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 6px; padding: 8px 10px; color: #e2e8f0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; transition: border-color .2s; }
|
|
||||||
.obj-card input:focus, .obj-card textarea:focus { border-color: #60a5fa; }
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn-del { background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 13px; transition: all .2s; white-space: nowrap; flex-shrink: 0; }
|
|
||||||
.btn-del:hover { background: #3f1c1c; }
|
|
||||||
.btn-del-card { position: absolute; top: 10px; right: 10px; background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 12px; transition: all .2s; }
|
|
||||||
.btn-del-card:hover { background: #3f1c1c; }
|
|
||||||
|
|
||||||
.btn-add { background: transparent; border: 1px dashed #334155; color: #64748b; border-radius: 8px; padding: 9px 16px; cursor: pointer; font-size: 13px; width: 100%; text-align: center; transition: all .2s; margin-top: 6px; }
|
|
||||||
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; background: #1a1535; }
|
|
||||||
|
|
||||||
/* Bottom bar */
|
|
||||||
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
|
|
||||||
.btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
|
|
||||||
.btn-logout:hover { background: #374151; }
|
|
||||||
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
|
||||||
.btn-save:hover { opacity: .9; transform: translateY(-1px); }
|
|
||||||
.btn-save:active { transform: translateY(0); }
|
|
||||||
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
|
||||||
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
|
|
||||||
.btn-publish:active { transform: translateY(0); }
|
|
||||||
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; margin-left: auto; }
|
|
||||||
.preview-link:hover { color: #94a3b8; }
|
|
||||||
.save-status { font-size: 13px; font-weight: 500; display: none; margin-left: 8px; }
|
|
||||||
|
|
||||||
/* Toast */
|
|
||||||
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
|
|
||||||
.toast.ok { background: #064e3b; border: 1px solid #10b981; color: #6ee7b7; }
|
|
||||||
.toast.err { background: #450a0a; border: 1px solid #ef4444; color: #fca5a5; }
|
|
||||||
@keyframes slideIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
${CMS_DEPLOY_ENV === 'staging' ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
|
|
||||||
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${message.text}</div>` : ''}
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1>mozdIT Content Editor</h1>
|
|
||||||
<span>— JSON fájlok szerkesztése vizuálisan</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav class="tabs">
|
|
||||||
${Object.entries(FILE_LABELS).map(([k, l]) =>
|
|
||||||
`<a href="/?file=${k}" class="tab ${activeFile === k ? 'active' : ''}">${l}</a>`
|
|
||||||
).join('')}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<p class="hint">📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (➕). Mentés gomb menti a fájlt.</p>
|
|
||||||
<div id="editor"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-bar">
|
|
||||||
<button class="btn-save" onclick="save()">💾 Mentés</button>
|
|
||||||
<button class="btn-publish" onclick="publish()" id="publishBtn">🚀 Publikálás & ${CMS_DEPLOY_ENV === 'staging' ? 'Staging deploy' : 'Élesítés'}</button>
|
|
||||||
<span class="save-status" id="saveStatus"></span>
|
|
||||||
<a href="${CMS_DEPLOY_ENV === 'staging' ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
|
|
||||||
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
|
|
||||||
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script id="page-data" type="application/json">${jsonData.replace(/<\//g, '<\\/')}</script>
|
|
||||||
<script>
|
|
||||||
const DATA = JSON.parse(document.getElementById('page-data').textContent);
|
|
||||||
const FILE = "${activeFile}";
|
|
||||||
const CSRF_TOKEN = "${csrfToken}";
|
|
||||||
|
|
||||||
// ── 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)
|
|
||||||
});
|
|
||||||
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 } });
|
|
||||||
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() {
|
|
||||||
// WHY: the browser caches Basic Auth credentials until it closes. One request with
|
|
||||||
// deliberately invalid credentials overwrites the cache, so the reload below
|
|
||||||
// prompts for login again. fetch() never triggers the native auth dialog.
|
|
||||||
try {
|
|
||||||
await fetch('/logout', { headers: { 'Authorization': 'Basic ' + btoa('logout:logout') } });
|
|
||||||
} catch (e) { /* network error — reload anyway */ }
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(v) {
|
|
||||||
return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boot
|
|
||||||
render(DATA, document.getElementById('editor'));
|
|
||||||
|
|
||||||
// Auto-dismiss toast
|
|
||||||
const toast = document.querySelector('.toast');
|
|
||||||
if (toast) setTimeout(() => toast.remove(), 3500);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme.
|
|
||||||
const GUIDE_PAGE = (contentHtml) => `<!DOCTYPE html>
|
|
||||||
<html lang="hu">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>mozdIT — Felhasználói útmutató</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.65; padding-bottom: 64px; }
|
|
||||||
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
|
|
||||||
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
||||||
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
|
|
||||||
header a:hover { color: #e2e8f0; }
|
|
||||||
main { max-width: 760px; margin: 0 auto; padding: 32px 24px; }
|
|
||||||
h1 { font-size: 24px; margin: 16px 0 12px; color: #f1f5f9; }
|
|
||||||
h2 { font-size: 20px; margin: 28px 0 10px; color: #93c5fd; border-bottom: 1px solid #2d3748; padding-bottom: 6px; }
|
|
||||||
h3 { font-size: 16px; margin: 20px 0 8px; color: #c4b5fd; }
|
|
||||||
h4 { font-size: 14px; margin: 16px 0 6px; color: #c4b5fd; }
|
|
||||||
p { margin: 8px 0; }
|
|
||||||
ul, ol { margin: 8px 0 8px 22px; }
|
|
||||||
li { margin: 4px 0; }
|
|
||||||
a { color: #7dd3fc; }
|
|
||||||
code { background: #1e293b; border-radius: 4px; padding: 1px 6px; font-size: 0.9em; color: #fbbf24; }
|
|
||||||
pre { background: #1e293b; border: 1px solid #2d3748; border-radius: 8px; padding: 12px 16px; overflow-x: auto; margin: 12px 0; }
|
|
||||||
pre code { background: none; padding: 0; color: #e2e8f0; }
|
|
||||||
hr { border: none; border-top: 1px solid #2d3748; margin: 24px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1>mozdIT — Felhasználói útmutató</h1>
|
|
||||||
<a href="/">← Vissza a szerkesztőhöz</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
${contentHtml}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
// ── Server ───────────────────────────────────────────────────────────────────
|
// ── Server ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -545,9 +89,11 @@ function exceedsRateLimit(key, limit) {
|
|||||||
function hasValidCredentials(req) {
|
function hasValidCredentials(req) {
|
||||||
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
||||||
const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':');
|
const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':');
|
||||||
if (!CMS_USER || !CMS_PASS || login.length !== CMS_USER.length || password.length !== CMS_PASS.length) return false;
|
return validateLogin(login, password, CMS_USER, CMS_PASS);
|
||||||
return crypto.timingSafeEqual(Buffer.from(login), Buffer.from(CMS_USER))
|
}
|
||||||
&& crypto.timingSafeEqual(Buffer.from(password), Buffer.from(CMS_PASS));
|
|
||||||
|
function isAuthenticated(req) {
|
||||||
|
return hasValidCredentials(req) || hasValidSession(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasValidCsrfToken(req) {
|
function hasValidCsrfToken(req) {
|
||||||
@@ -589,13 +135,69 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// no native logout. The client calls /logout with deliberately invalid credentials,
|
// no native logout. The client calls /logout with deliberately invalid credentials,
|
||||||
// which overwrites the cached pair; the next navigation prompts for login again.
|
// which overwrites the cached pair; the next navigation prompts for login again.
|
||||||
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
|
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
|
||||||
if (u.pathname === '/logout') {
|
if (u.pathname === '/logout' && req.method === 'GET') {
|
||||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
||||||
res.end('Logged out');
|
res.end('Logged out');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasValidCredentials(req)) {
|
// Public: logo asset for the login page.
|
||||||
|
if (req.method === 'GET' && u.pathname === '/logo.png') {
|
||||||
|
try {
|
||||||
|
const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', 'mozdit_logo.png'));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
|
||||||
|
res.end(logo);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404); res.end('Not found');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: styled login page (shown after logout and for unauthenticated browser visits).
|
||||||
|
if (req.method === 'GET' && u.pathname === '/login') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(LOGIN_PAGE());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: login form endpoint. Shares the auth rate-limit budget with failed
|
||||||
|
// Basic attempts so the form cannot be brute-forced either.
|
||||||
|
if (req.method === 'POST' && u.pathname === '/login') {
|
||||||
|
if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
|
||||||
|
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
|
||||||
|
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
let bodyTooLarge = false;
|
||||||
|
req.on('data', c => {
|
||||||
|
if (body.length + c.length > 1024) { bodyTooLarge = true; return; }
|
||||||
|
body += c;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
let user = '';
|
||||||
|
let pass = '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
user = String(parsed.user || '');
|
||||||
|
pass = String(parsed.pass || '');
|
||||||
|
} catch { /* empty credentials fail validation below */ }
|
||||||
|
if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) {
|
||||||
|
const isSecure = req.headers['x-forwarded-proto'] === 'https';
|
||||||
|
writeAudit('login_success', { clientAddress });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated(req)) {
|
||||||
const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS);
|
const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS);
|
||||||
writeAudit('authentication_failed', { clientAddress, limited });
|
writeAudit('authentication_failed', { clientAddress, limited });
|
||||||
if (limited) {
|
if (limited) {
|
||||||
@@ -603,6 +205,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
res.end('Too many authentication attempts');
|
res.end('Too many authentication attempts');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Browser navigations land on the styled login page; API/curl keeps the 401 challenge.
|
||||||
|
const acceptsHtml = String(req.headers.accept || '').includes('text/html');
|
||||||
|
if (acceptsHtml && req.method === 'GET') {
|
||||||
|
res.writeHead(302, { Location: '/login' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
||||||
res.end('Access denied');
|
res.end('Access denied');
|
||||||
return;
|
return;
|
||||||
@@ -618,6 +227,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /logout — invalidate the browser session (Basic Auth stays valid by design).
|
||||||
|
if (req.method === 'POST' && u.pathname === '/logout') {
|
||||||
|
deleteSession(req);
|
||||||
|
writeAudit('logout', { clientAddress, user: CMS_USER });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /guide — user guide rendered from the maintained markdown in the repo.
|
// GET /guide — user guide rendered from the maintained markdown in the repo.
|
||||||
if (req.method === 'GET' && u.pathname === '/guide') {
|
if (req.method === 'GET' && u.pathname === '/guide') {
|
||||||
let contentHtml;
|
let contentHtml;
|
||||||
@@ -712,7 +330,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN));
|
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció
|
|||||||
### Belépés és kilépés
|
### Belépés és kilépés
|
||||||
|
|
||||||
- A CMS a kiadott címen érhető el (staging: `https://cms.stage.llmdev.mozdit.hu`).
|
- A CMS a kiadott címen érhető el (staging: `https://cms.stage.llmdev.mozdit.hu`).
|
||||||
- Belépés: a megadott **felhasználónév + jelszó** párossal (ezt az adminisztrátor adja).
|
- **Bejelentkezés**: a logós bejelentkező oldalon add meg a **felhasználónevet és jelszót** (ezt az adminisztrátor adja), majd kattints a Belépés gombra.
|
||||||
- **🚪 Kilépés**: az alsó sáv gombja — anélkül jelentkezel ki, hogy be kellene zárnod a böngészőt.
|
- Több **sikertelen próbálkozás** (5) után a belépés kb. 15 percre zárolásra kerül.
|
||||||
|
- A belépés **8 óráig érvényes** — ezután a CMS visszairányít a bejelentkező oldalra, ahol újra meg kell adni a jelszót.
|
||||||
|
- **🚪 Kilépés**: az alsó sáv gombja — egy megerősítő kérdés („Biztosan ki szeretnél lépni?") után kijelentkezel, és megjelenik a bejelentkező oldal.
|
||||||
|
|
||||||
### Felület áttekintés
|
### Felület áttekintés
|
||||||
|
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot
|
||||||
|
render(DATA, document.getElementById('editor'));
|
||||||
|
|
||||||
|
// Auto-dismiss toast
|
||||||
|
const toast = document.querySelector('.toast');
|
||||||
|
if (toast) setTimeout(() => toast.remove(), 3500);
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Page templates for the Content Editor. Kept separate so content-editor.js
|
||||||
|
// stays focused on routing/handling and below the file-size limits.
|
||||||
|
|
||||||
|
const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging';
|
||||||
|
|
||||||
|
// FILE_LABELS is injected to avoid a circular dependency with the main file.
|
||||||
|
const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs) => `<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${isStaging() ? 'STAGING — ' : ''}mozdIT Content Editor</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
|
||||||
|
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
header span { color: #64748b; font-size: 13px; }
|
||||||
|
|
||||||
|
.tabs { display: flex; gap: 2px; padding: 14px 32px 0; border-bottom: 1px solid #2d3748; background: #13192a; }
|
||||||
|
.tab { text-decoration: none; color: #94a3b8; padding: 9px 16px; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 500; transition: all .2s; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
|
||||||
|
.tab:hover { color: #e2e8f0; background: #1e2535; }
|
||||||
|
.tab.active { color: #60a5fa; background: #0f1117; border-color: #2d3748; }
|
||||||
|
|
||||||
|
.page { max-width: 860px; margin: 28px auto 120px; padding: 0 24px; }
|
||||||
|
.hint { color: #475569; font-size: 12px; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
/* Primitive field */
|
||||||
|
.field { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 16px; transition: border-color .2s; margin-bottom: 10px; }
|
||||||
|
.field:focus-within { border-color: #60a5fa; }
|
||||||
|
.field label { display: block; font-size: 11px; font-weight: 600; color: #60a5fa; text-transform: uppercase; letter-spacing:.05em; margin-bottom: 7px; font-family: monospace; }
|
||||||
|
.field input, .field textarea { width: 100%; background: transparent; border: none; outline: none; color: #e2e8f0; font-size: 14px; line-height: 1.6; resize: vertical; font-family: inherit; }
|
||||||
|
.field textarea { min-height: 52px; }
|
||||||
|
|
||||||
|
/* Array section */
|
||||||
|
.array-section { margin-bottom: 20px; }
|
||||||
|
.array-label { font-size: 12px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing:.06em; font-family: monospace; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.array-label::after { content:''; flex: 1; height: 1px; background: #2d3748; }
|
||||||
|
|
||||||
|
.array-items { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
/* Simple string array item */
|
||||||
|
.str-item { display: flex; gap: 8px; align-items: flex-start; }
|
||||||
|
.str-item textarea { flex: 1; background: #1a2035; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; color: #e2e8f0; font-size: 14px; font-family: inherit; outline: none; resize: vertical; min-height: 44px; transition: border-color .2s; }
|
||||||
|
.str-item textarea:focus { border-color: #60a5fa; }
|
||||||
|
|
||||||
|
/* Object array item (card) */
|
||||||
|
.obj-card { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; position: relative; }
|
||||||
|
.obj-card .card-header { font-size: 11px; color: #64748b; font-family: monospace; margin-bottom: 10px; }
|
||||||
|
.obj-card .inner-field { margin-bottom: 8px; }
|
||||||
|
.obj-card .inner-field:last-child { margin-bottom: 0; }
|
||||||
|
.obj-card .inner-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing:.05em; font-family: monospace; margin-bottom: 4px; }
|
||||||
|
.obj-card input, .obj-card textarea { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 6px; padding: 8px 10px; color: #e2e8f0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; transition: border-color .2s; }
|
||||||
|
.obj-card input:focus, .obj-card textarea:focus { border-color: #60a5fa; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-del { background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 13px; transition: all .2s; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.btn-del:hover { background: #3f1c1c; }
|
||||||
|
.btn-del-card { position: absolute; top: 10px; right: 10px; background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 12px; transition: all .2s; }
|
||||||
|
.btn-del-card:hover { background: #3f1c1c; }
|
||||||
|
|
||||||
|
.btn-add { background: transparent; border: 1px dashed #334155; color: #64748b; border-radius: 8px; padding: 9px 16px; cursor: pointer; font-size: 13px; width: 100%; text-align: center; transition: all .2s; margin-top: 6px; }
|
||||||
|
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; background: #1a1535; }
|
||||||
|
|
||||||
|
/* Bottom bar */
|
||||||
|
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
|
||||||
|
.btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
|
||||||
|
.btn-logout:hover { background: #374151; }
|
||||||
|
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
||||||
|
.btn-save:hover { opacity: .9; transform: translateY(-1px); }
|
||||||
|
.btn-save:active { transform: translateY(0); }
|
||||||
|
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
||||||
|
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
|
||||||
|
.btn-publish:active { transform: translateY(0); }
|
||||||
|
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; margin-left: auto; }
|
||||||
|
.preview-link:hover { color: #94a3b8; }
|
||||||
|
.save-status { font-size: 13px; font-weight: 500; display: none; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
|
||||||
|
.toast.ok { background: #064e3b; border: 1px solid #10b981; color: #6ee7b7; }
|
||||||
|
.toast.err { background: #450a0a; border: 1px solid #ef4444; color: #fca5a5; }
|
||||||
|
@keyframes slideIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
|
||||||
|
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${message.text}</div>` : ''}
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>mozdIT Content Editor</h1>
|
||||||
|
<span>— JSON fájlok szerkesztése vizuálisan</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
${Object.entries(fileLabels).map(([k, l]) =>
|
||||||
|
`<a href="/?file=${k}" class="tab ${activeFile === k ? 'active' : ''}">${l}</a>`
|
||||||
|
).join('')}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<p class="hint">📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (➕). Mentés gomb menti a fájlt.</p>
|
||||||
|
<div id="editor"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-bar">
|
||||||
|
<button class="btn-save" onclick="save()">💾 Mentés</button>
|
||||||
|
<button class="btn-publish" onclick="publish()" id="publishBtn">🚀 Publikálás & ${isStaging() ? 'Staging deploy' : 'Élesítés'}</button>
|
||||||
|
<span class="save-status" id="saveStatus"></span>
|
||||||
|
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
|
||||||
|
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
|
||||||
|
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script id="page-data" type="application/json">${jsonData.replace(/<\//g, '<\\/')}</script>
|
||||||
|
<script>
|
||||||
|
const DATA = JSON.parse(document.getElementById('page-data').textContent);
|
||||||
|
const FILE = "${activeFile}";
|
||||||
|
const CSRF_TOKEN = "${csrfToken}";
|
||||||
|
|
||||||
|
${clientJs}
|
||||||
|
</script>
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme.
|
||||||
|
const GUIDE_PAGE = (contentHtml) => `<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>mozdIT — Felhasználói útmutató</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.65; padding-bottom: 64px; }
|
||||||
|
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
|
||||||
|
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
|
||||||
|
header a:hover { color: #e2e8f0; }
|
||||||
|
main { max-width: 760px; margin: 0 auto; padding: 32px 24px; }
|
||||||
|
h1 { font-size: 24px; margin: 16px 0 12px; color: #f1f5f9; }
|
||||||
|
h2 { font-size: 20px; margin: 28px 0 10px; color: #93c5fd; border-bottom: 1px solid #2d3748; padding-bottom: 6px; }
|
||||||
|
h3 { font-size: 16px; margin: 20px 0 8px; color: #c4b5fd; }
|
||||||
|
h4 { font-size: 14px; margin: 16px 0 6px; color: #c4b5fd; }
|
||||||
|
p { margin: 8px 0; }
|
||||||
|
ul, ol { margin: 8px 0 8px 22px; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
a { color: #7dd3fc; }
|
||||||
|
code { background: #1e293b; border-radius: 4px; padding: 1px 6px; font-size: 0.9em; color: #fbbf24; }
|
||||||
|
pre { background: #1e293b; border: 1px solid #2d3748; border-radius: 8px; padding: 12px 16px; overflow-x: auto; margin: 12px 0; }
|
||||||
|
pre code { background: none; padding: 0; color: #e2e8f0; }
|
||||||
|
hr { border: none; border-top: 1px solid #2d3748; margin: 24px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>mozdIT — Felhasználói útmutató</h1>
|
||||||
|
<a href="/">← Vissza a szerkesztőhöz</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
${contentHtml}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// Login page — simple logo page shown after logout (and for unauthenticated browser visits).
|
||||||
|
const LOGIN_PAGE = () => `<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>mozdIT CMS — Belépés</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||||
|
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; position: fixed; top: 0; left: 0; right: 0; }
|
||||||
|
.card { background: linear-gradient(160deg,#1a1f2e,#252d40); border: 1px solid #2d3748; border-radius: 16px; padding: 40px 36px; width: 100%; max-width: 380px; box-shadow: 0 20px 50px rgba(0,0,0,.45); }
|
||||||
|
.logo { text-align: center; margin-bottom: 28px; }
|
||||||
|
.logo img { height: 56px; }
|
||||||
|
h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #94a3b8; font-size: 14px; text-align: center; margin-bottom: 26px; }
|
||||||
|
label { display: block; font-size: 13px; color: #94a3b8; margin: 14px 0 6px; }
|
||||||
|
input { width: 100%; background: #0f1117; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 11px 14px; font-size: 15px; }
|
||||||
|
input:focus { outline: none; border-color: #60a5fa; }
|
||||||
|
button { width: 100%; margin-top: 24px; background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 12px; font-size: 15px; font-weight: 700; cursor: pointer; }
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
button:disabled { opacity: .6; cursor: wait; }
|
||||||
|
.error { color: #f87171; font-size: 14px; text-align: center; margin-top: 14px; min-height: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET</div>' : ''}
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo"><img src="/logo.png" alt="mozdIT"></div>
|
||||||
|
<h1>Content Editor</h1>
|
||||||
|
<p class="subtitle">Belépés a tartalomszerkesztőbe</p>
|
||||||
|
<form onsubmit="return login(event)">
|
||||||
|
<label for="user">Felhasználónév</label>
|
||||||
|
<input id="user" name="user" autocomplete="username" autofocus required>
|
||||||
|
<label for="pass">Jelszó</label>
|
||||||
|
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit" id="btn">Belépés</button>
|
||||||
|
</form>
|
||||||
|
<p class="error" id="err"></p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function login(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('btn');
|
||||||
|
const err = document.getElementById('err');
|
||||||
|
btn.disabled = true; err.textContent = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user: document.getElementById('user').value, pass: document.getElementById('pass').value })
|
||||||
|
});
|
||||||
|
if (res.ok) { location.href = '/'; return; }
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
err.textContent = json.error || 'Sikertelen belépés — ellenőrizd a felhasználónevet és a jelszót.';
|
||||||
|
} catch (e2) {
|
||||||
|
err.textContent = 'Hálózati hiba — próbáld újra.';
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// WHY: Basic Auth has no native logout and its dialog cannot be styled, so a
|
||||||
|
// successful /login form submit receives a server-side session token in an
|
||||||
|
// HttpOnly cookie. Basic Auth remains valid in parallel (curl, API use).
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'cms_session';
|
||||||
|
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||||
|
const sessions = new Map(); // token -> expiresAt (ms)
|
||||||
|
|
||||||
|
function timingSafeMatch(candidate, expected) {
|
||||||
|
if (typeof candidate !== 'string' || typeof expected !== 'string' || candidate.length !== expected.length) return false;
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLogin(user, pass, expectedUser, expectedPass) {
|
||||||
|
if (!expectedUser || !expectedPass) return false;
|
||||||
|
return timingSafeMatch(user, expectedUser) && timingSafeMatch(pass, expectedPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionCookie(isSecure) {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
sessions.set(token, Date.now() + SESSION_TTL_MS);
|
||||||
|
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${isSecure ? '; Secure' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie() {
|
||||||
|
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionToken(req) {
|
||||||
|
const cookies = req.headers.cookie || '';
|
||||||
|
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-f0-9]+)`));
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidSession(req) {
|
||||||
|
const token = getSessionToken(req);
|
||||||
|
if (!token) return false;
|
||||||
|
const expiresAt = sessions.get(token);
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
if (Date.now() > expiresAt) {
|
||||||
|
sessions.delete(token);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSession(req) {
|
||||||
|
const token = getSessionToken(req);
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_TTL_MS,
|
||||||
|
timingSafeMatch,
|
||||||
|
validateLogin,
|
||||||
|
createSessionCookie,
|
||||||
|
clearSessionCookie,
|
||||||
|
hasValidSession,
|
||||||
|
deleteSession,
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for the CMS login flow (MITHOME-58):
|
||||||
|
* 1. GET /login is public and serves the styled login page with the logo
|
||||||
|
* 2. GET /logo.png is public
|
||||||
|
* 3. POST /login with wrong credentials → 401; with correct ones → 200 + session cookie
|
||||||
|
* 4. The session cookie authenticates GET / (200) where no Basic credentials exist
|
||||||
|
* 5. POST /logout (cookie + CSRF) invalidates the session; GET / with the dead
|
||||||
|
* cookie now redirects to /login for browser navigations
|
||||||
|
* 6. Non-browser requests without credentials still get the 401 challenge
|
||||||
|
* 7. Failed form logins count toward the auth rate limiter (6th → 429)
|
||||||
|
*/
|
||||||
|
const assert = require('assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function startServer(port) {
|
||||||
|
const auditFile = path.join(os.tmpdir(), `content-editor-audit-login-${port}-${process.pid}.jsonl`);
|
||||||
|
const child = spawn('node', ['content-editor.js'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CONTENT_EDITOR_PORT: String(port),
|
||||||
|
CONTENT_EDITOR_AUDIT_FILE: auditFile,
|
||||||
|
CMS_USER: 'login-test-user',
|
||||||
|
CMS_PASS: 'login-test-pass',
|
||||||
|
CMS_DEPLOY_ENV: 'staging',
|
||||||
|
},
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
return { child, auditFile };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServer(base, timeoutMs = 10000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await fetch(`${base}/logout`); // rate-limit-free readiness probe (GET)
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('server did not start');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// ── Happy path server ──────────────────────────────────────────────────────
|
||||||
|
const PORT = 4125;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
const s1 = startServer(PORT);
|
||||||
|
try {
|
||||||
|
await waitForServer(BASE);
|
||||||
|
|
||||||
|
// 1. login page is public
|
||||||
|
const page = await fetch(`${BASE}/login`);
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
const pageHtml = await page.text();
|
||||||
|
assert.match(pageHtml, /mozdIT CMS — Belépés/);
|
||||||
|
assert.match(pageHtml, /\/logo\.png/);
|
||||||
|
|
||||||
|
// 2. logo is public
|
||||||
|
const logo = await fetch(`${BASE}/logo.png`);
|
||||||
|
assert.equal(logo.status, 200);
|
||||||
|
assert.match(logo.headers.get('content-type') || '', /image\/png/);
|
||||||
|
|
||||||
|
// 3a. wrong credentials
|
||||||
|
const bad = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user: 'login-test-user', pass: 'wrong' }),
|
||||||
|
});
|
||||||
|
assert.equal(bad.status, 401);
|
||||||
|
|
||||||
|
// 3b. correct credentials → session cookie
|
||||||
|
const good = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user: 'login-test-user', pass: 'login-test-pass' }),
|
||||||
|
});
|
||||||
|
assert.equal(good.status, 200);
|
||||||
|
assert.deepEqual(await good.json(), { ok: true });
|
||||||
|
const setCookie = good.headers.get('set-cookie') || '';
|
||||||
|
assert.match(setCookie, /cms_session=[a-f0-9]+/);
|
||||||
|
assert.match(setCookie, /HttpOnly/);
|
||||||
|
assert.match(setCookie, /SameSite=Strict/);
|
||||||
|
// HTTP test run (no x-forwarded-proto) must NOT set Secure, or the cookie would be unusable
|
||||||
|
assert.doesNotMatch(setCookie, /Secure/);
|
||||||
|
const sessionCookie = setCookie.split(';')[0];
|
||||||
|
|
||||||
|
// 4. session cookie authenticates without Basic credentials
|
||||||
|
const authed = await fetch(`${BASE}/`, { headers: { Cookie: sessionCookie } });
|
||||||
|
assert.equal(authed.status, 200);
|
||||||
|
|
||||||
|
// Extract the CSRF token from the served editor page for the logout POST
|
||||||
|
const editorHtml = await authed.text();
|
||||||
|
const csrf = editorHtml.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
|
||||||
|
|
||||||
|
// 5. POST /logout kills the session
|
||||||
|
const logout = await fetch(`${BASE}/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: sessionCookie, 'X-CSRF-Token': csrf },
|
||||||
|
});
|
||||||
|
assert.equal(logout.status, 200);
|
||||||
|
|
||||||
|
// Dead cookie + browser navigation → redirect to /login
|
||||||
|
const redirected = await fetch(`${BASE}/`, {
|
||||||
|
headers: { Cookie: sessionCookie, Accept: 'text/html,application/xhtml+xml' },
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
assert.equal(redirected.status, 302);
|
||||||
|
assert.equal(redirected.headers.get('location'), '/login');
|
||||||
|
|
||||||
|
// 6. non-browser requests keep the 401 challenge (curl/API compatibility)
|
||||||
|
const apiStyle = await fetch(`${BASE}/`);
|
||||||
|
assert.equal(apiStyle.status, 401);
|
||||||
|
assert.match(apiStyle.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/);
|
||||||
|
|
||||||
|
console.log('Content Editor login flow test: OK');
|
||||||
|
} finally {
|
||||||
|
s1.child.kill('SIGTERM');
|
||||||
|
try { fs.unlinkSync(s1.auditFile); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate-limit server (fresh limiter state) ────────────────────────────────
|
||||||
|
const PORT2 = 4126;
|
||||||
|
const BASE2 = `http://127.0.0.1:${PORT2}`;
|
||||||
|
const s2 = startServer(PORT2);
|
||||||
|
try {
|
||||||
|
await waitForServer(BASE2);
|
||||||
|
const attempt = () => fetch(`${BASE2}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user: 'login-test-user', pass: 'wrong' }),
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
assert.equal((await attempt()).status, 401);
|
||||||
|
}
|
||||||
|
const limited = await attempt();
|
||||||
|
assert.equal(limited.status, 429);
|
||||||
|
console.log('Content Editor login rate-limit test: OK');
|
||||||
|
} finally {
|
||||||
|
s2.child.kill('SIGTERM');
|
||||||
|
try { fs.unlinkSync(s2.auditFile); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('❌', err.message); process.exitCode = 1; });
|
||||||
Reference in New Issue
Block a user