1613 lines
76 KiB
HTML
1613 lines
76 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Data Management · Infosec</title>
|
|
<link rel="stylesheet" href="/css/style.css">
|
|
<style>
|
|
.tabs { display:flex; gap:4px; border-bottom:1px solid var(--line); margin-bottom:20px; }
|
|
.tabs button { border:none; background:none; padding:11px 16px; font-size:14.5px; font-weight:600;
|
|
color:var(--muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; }
|
|
.tabs button.active { color:var(--accent-ink); border-bottom-color:var(--accent); }
|
|
.req-count { background:var(--danger); color:#fff; border-radius:999px; font-size:11px; padding:1px 7px; margin-left:6px; }
|
|
|
|
/* ---- Data Creation ---- */
|
|
.dc-modes { display:flex; gap:8px; margin-bottom:18px; }
|
|
.dc-modes button { border:1px solid var(--line); background:#fff; border-radius:9px; padding:9px 16px;
|
|
font-weight:600; font-size:14px; color:var(--muted); cursor:pointer; }
|
|
.dc-modes button.active { border-color:var(--accent); color:var(--accent-ink); background:rgba(42,165,150,.08); }
|
|
.dc-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
|
@media (max-width:720px){ .dc-grid { grid-template-columns:1fr; } }
|
|
.dc-block { border:1px solid var(--line); border-radius:11px; padding:14px; margin-bottom:12px; background:#fff; }
|
|
.dc-block-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:9px; }
|
|
.dc-block-head .dc-kind { font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); }
|
|
.dc-block-head .dc-tools { display:flex; gap:4px; }
|
|
.dc-block textarea, .dc-ev textarea { font-family:inherit; font-size:14px; min-height:84px; }
|
|
.dc-imgprev { max-height:120px; max-width:100%; border-radius:8px; border:1px solid var(--line); margin-top:8px; display:block; }
|
|
.dc-ev { border:1px solid var(--line); border-radius:12px; padding:14px; margin-bottom:14px; background:#fff; }
|
|
.dc-ev.branch { margin-left:22px; border-left:3px solid var(--accent); background:rgba(42,165,150,.04); }
|
|
.dc-ev-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
|
|
.dc-ev-head .dc-ev-lbl { font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); }
|
|
.dc-empty { color:var(--muted); font-size:14px; padding:10px 2px 16px; }
|
|
.dc-addrow { display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 20px; }
|
|
.dc-actions { display:flex; gap:10px; align-items:center; margin-top:6px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script src="/js/app.js"></script>
|
|
<script>
|
|
(async () => {
|
|
const shell = await buildShell('manage'); if (!shell) return;
|
|
const { content, me } = shell;
|
|
let tree = { folders: [], files: [] };
|
|
let requests = [];
|
|
let templates = [];
|
|
let images = [];
|
|
let users = [];
|
|
let versions = [];
|
|
let legislation = [];
|
|
let mailCfg = null;
|
|
let mailLog = { entries: [], total: 0 };
|
|
let mailFilter = { q: '', direction: '', status: '' };
|
|
let tab = 'files';
|
|
|
|
// ---- Data Creation working state (kept across re-renders) ----
|
|
let dcMode = 'document';
|
|
const dcDoc = { folder_id: '', name: '', title: '', blocks: [] };
|
|
const dcTL = { folder_id: '', name: '', title: '', events: [] };
|
|
const newTLEvent = () => ({ name: '', date: '', time: '', data: '', branches: [] });
|
|
|
|
content.innerHTML = `
|
|
<div class="page-head">
|
|
<h1>Data Management</h1>
|
|
<p>Publish data, review access requests, and configure notifications.</p>
|
|
</div>
|
|
<div class="tabs" id="tabs"></div>
|
|
<div id="tabView"></div>`;
|
|
|
|
const tabs = [
|
|
{ key: 'files', label: 'Files & Folders' },
|
|
{ key: 'datacreate', label: 'Data Creation' },
|
|
{ key: 'requests', label: 'Access Requests' },
|
|
{ key: 'templates', label: 'Email Templates' },
|
|
{ key: 'images', label: 'Brand Images' },
|
|
{ key: 'users', label: 'Users' },
|
|
{ key: 'versions', label: 'Version Control' },
|
|
{ key: 'legislation', label: 'Legislation' },
|
|
{ key: 'mailcfg', label: 'Email Server' },
|
|
{ key: 'maillog', label: 'Email Log' },
|
|
];
|
|
|
|
function renderTabs() {
|
|
const pending = requests.filter((r) => r.status === 'pending').length;
|
|
document.getElementById('tabs').innerHTML = tabs.map((t) =>
|
|
`<button class="${tab === t.key ? 'active' : ''}" data-tab="${t.key}">${t.label}${t.key === 'requests' && pending ? `<span class="req-count">${pending}</span>` : ''}</button>`
|
|
).join('');
|
|
document.querySelectorAll('[data-tab]').forEach((b) => b.onclick = () => { tab = b.dataset.tab; renderTabs(); renderTab(); });
|
|
}
|
|
|
|
// ---------------- FILES & FOLDERS ----------------
|
|
function folderOptions(selected, exclude) {
|
|
const opts = ['<option value="">— Root —</option>'];
|
|
const walk = (parentId, depth) => {
|
|
tree.folders.filter((f) => (f.parent_id || null) === parentId).forEach((f) => {
|
|
if (f.id === exclude) return;
|
|
opts.push(`<option value="${f.id}" ${f.id == selected ? 'selected' : ''}>${'\u00A0\u00A0'.repeat(depth)}${esc(f.name)}</option>`);
|
|
walk(f.id, depth + 1);
|
|
});
|
|
};
|
|
walk(null, 0);
|
|
return opts.join('');
|
|
}
|
|
|
|
// Which folders are expanded. Persisted across re-renders so the tree keeps
|
|
// its shape after an upload, rename, or delete.
|
|
const expanded = new Set();
|
|
|
|
function childCounts(folderId) {
|
|
const subs = tree.folders.filter((f) => (f.parent_id || null) === folderId).length;
|
|
const fls = tree.files.filter((f) => (f.folder_id || null) === folderId).length;
|
|
return { subs, files: fls, total: subs + fls };
|
|
}
|
|
|
|
function countLabel(c) {
|
|
if (!c.total) return '<span class="child-count">empty</span>';
|
|
const bits = [];
|
|
if (c.subs) bits.push(`${c.subs} folder${c.subs === 1 ? '' : 's'}`);
|
|
if (c.files) bits.push(`${c.files} file${c.files === 1 ? '' : 's'}`);
|
|
return `<span class="child-count">${bits.join(', ')}</span>`;
|
|
}
|
|
|
|
function dateLabel(fo) {
|
|
if (!fo.recorded_date) return '<span class="fdate missing">no incident date</span>';
|
|
return `<span class="fdate">inc ${esc(fmtDate(fo.recorded_date))} · valid to ${
|
|
esc(fmtDate(fo.legal_validity) || '—')}</span>`;
|
|
}
|
|
|
|
function renderTreeNode(parentId) {
|
|
const folders = tree.folders.filter((f) => (f.parent_id || null) === parentId);
|
|
const files = tree.files.filter((f) => (f.folder_id || null) === parentId);
|
|
if (!folders.length && !files.length) return '';
|
|
let html = '<ul class="tree">';
|
|
for (const fo of folders) {
|
|
const c = childCounts(fo.id);
|
|
const isOpen = expanded.has(fo.id);
|
|
const caretCls = 'caret' + (isOpen ? ' open' : '') + (c.total ? '' : ' leaf');
|
|
html += `<li><div class="row">
|
|
<button class="${caretCls}" data-toggle="${fo.id}" title="${isOpen ? 'Collapse' : 'Expand'}"
|
|
aria-expanded="${isOpen}">${ICON.caret}</button>
|
|
<span class="ic folder">${ICON.folder}</span>
|
|
<span class="name">${esc(fo.name)}</span>
|
|
${countLabel(c)}
|
|
${dateLabel(fo)}
|
|
<span class="spacer"></span>
|
|
<span class="actions">
|
|
<button class="btn btn-sm btn-ghost" title="Upload here" data-up="${fo.id}">${ICON.upload}</button>
|
|
<button class="btn btn-sm btn-ghost" title="New sub-folder" data-newsub="${fo.id}">${ICON.plus}</button>
|
|
<button class="btn btn-sm btn-ghost" title="Rename" data-renf="${fo.id}" data-name="${esc(fo.name)}">${ICON.edit}</button>
|
|
<button class="btn btn-sm btn-ghost" title="Incident date" data-datef="${fo.id}" data-name="${esc(fo.name)}" data-date="${esc(fo.recorded_date || '')}">${ICON.calendar}</button>
|
|
<button class="btn btn-sm btn-ghost" title="Move" data-movf="${fo.id}">${ICON.move}</button>
|
|
<button class="btn btn-sm btn-ghost" title="Delete" data-delf="${fo.id}" data-name="${esc(fo.name)}">${ICON.trash}</button>
|
|
</span></div>${isOpen ? renderTreeNode(fo.id) : ''}</li>`;
|
|
}
|
|
for (const fi of files) {
|
|
html += `<li><div class="row">
|
|
<span class="caret leaf"></span>
|
|
<span class="ic">${ICON.file}</span>
|
|
<span class="name">${esc(fi.name)}</span>
|
|
<span class="meta">${fmtBytes(fi.size)}</span>
|
|
<span class="spacer"></span>
|
|
<span class="actions">
|
|
<button class="btn btn-sm btn-ghost" title="Rename" data-renfi="${fi.id}" data-name="${esc(fi.name)}">${ICON.edit}</button>
|
|
<button class="btn btn-sm btn-ghost" title="Move" data-movfi="${fi.id}">${ICON.move}</button>
|
|
<button class="btn btn-sm btn-ghost" title="Delete" data-delfi="${fi.id}" data-name="${esc(fi.name)}">${ICON.trash}</button>
|
|
</span></div></li>`;
|
|
}
|
|
return html + '</ul>';
|
|
}
|
|
|
|
function filesView() {
|
|
const undated = tree.folders.filter((f) => !f.recorded_date).length;
|
|
const undatedBanner = undated ? `
|
|
<div class="warn-banner">
|
|
<strong>${undated} folder(s) have no incident date.</strong>
|
|
These were created before dates were required, so no Legal Validity can be
|
|
shown for them on View Data. Use the calendar button on a folder to set one.
|
|
</div>` : '';
|
|
|
|
const body = renderTreeNode(null) ||
|
|
`<div class="empty">${ICON.folder}<div>No folders yet. Create one to get started.</div></div>`;
|
|
return `
|
|
${undatedBanner}
|
|
<div class="toolbar">
|
|
<button class="btn btn-primary" id="newFolder">${ICON.plus} New folder</button>
|
|
<button class="btn" id="uploadRoot">${ICON.upload} Upload to root</button>
|
|
<span class="hint">Everything here appears on the View Data page.</span>
|
|
</div>
|
|
<div class="card"><div class="card-body">
|
|
<div class="tw">
|
|
<div class="tw-toolbar">
|
|
<button class="btn btn-sm" id="expandAll">Expand all</button>
|
|
<button class="btn btn-sm" id="collapseAll">Collapse all</button>
|
|
<span class="hint">Click a folder's arrow to show or hide what is inside it.</span>
|
|
</div>
|
|
<div style="padding:12px 14px">${body}</div>
|
|
</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
function bindFiles() {
|
|
const q = (s) => document.querySelectorAll(s);
|
|
const byId = (id) => document.getElementById(id);
|
|
|
|
byId('newFolder').onclick = () => createFolder(null);
|
|
byId('uploadRoot').onclick = () => uploadTo(null);
|
|
|
|
// expand / collapse
|
|
q('[data-toggle]').forEach((b) => b.onclick = () => {
|
|
const id = Number(b.dataset.toggle);
|
|
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
|
|
renderTab();
|
|
});
|
|
byId('expandAll').onclick = () => {
|
|
tree.folders.forEach((f) => expanded.add(f.id));
|
|
renderTab();
|
|
};
|
|
byId('collapseAll').onclick = () => { expanded.clear(); renderTab(); };
|
|
q('[data-datef]').forEach((b) => b.onclick =
|
|
() => setFolderDate(Number(b.dataset.datef), b.dataset.name, b.dataset.date));
|
|
|
|
q('[data-newsub]').forEach((b) => b.onclick = () => createFolder(Number(b.dataset.newsub)));
|
|
q('[data-up]').forEach((b) => b.onclick = () => uploadTo(Number(b.dataset.up)));
|
|
q('[data-renf]').forEach((b) => b.onclick = () => renameFolder(Number(b.dataset.renf), b.dataset.name));
|
|
q('[data-movf]').forEach((b) => b.onclick = () => moveFolder(Number(b.dataset.movf)));
|
|
q('[data-delf]').forEach((b) => b.onclick = () => del('folder', Number(b.dataset.delf), b.dataset.name));
|
|
q('[data-renfi]').forEach((b) => b.onclick = () => renameFile(Number(b.dataset.renfi), b.dataset.name));
|
|
q('[data-movfi]').forEach((b) => b.onclick = () => moveFile(Number(b.dataset.movfi)));
|
|
q('[data-delfi]').forEach((b) => b.onclick = () => del('file', Number(b.dataset.delfi), b.dataset.name));
|
|
}
|
|
|
|
function createFolder(parentId) {
|
|
// A recorded date is required: Legal Validity (recorded + 30 days) is
|
|
// derived from it and shown to everyone on View Data.
|
|
const m = modal('New folder', `
|
|
<div class="field"><label>Folder name</label>
|
|
<input id="nf_name" placeholder="e.g. Camera 4 — North Gate" autofocus></div>
|
|
<div class="field"><label>Incident date <span class="req-star">*</span></label>
|
|
<input id="nf_date" value="${fmtDate(todayStr())}" placeholder="DD-MM-YYYY" maxlength="10">
|
|
<span class="hint">Format DD-MM-YYYY. The date of the incident;
|
|
Legal Validity is automatically this date plus 30 days.</span></div>
|
|
<div class="field"><label>Legal validity (calculated)</label>
|
|
<input id="nf_valid" value="${fmtDate(plus30(todayStr()))}" disabled></div>
|
|
<div class="err-line" id="nf_err"></div>`, {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Create',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const name = document.getElementById('nf_name').value.trim();
|
|
const typed = document.getElementById('nf_date').value.trim();
|
|
const recorded_date = toIsoDate(typed);
|
|
const err = document.getElementById('nf_err');
|
|
if (!name) { err.textContent = 'A folder name is required.'; return false; }
|
|
if (!typed) { err.textContent = 'An incident date is required.'; return false; }
|
|
if (!recorded_date) { err.textContent = 'Enter a valid date as DD-MM-YYYY.'; return false; }
|
|
(async () => {
|
|
try {
|
|
await api('/api/manage/folders', {
|
|
method: 'POST', body: { name, parent_id: parentId, recorded_date },
|
|
});
|
|
m.close();
|
|
if (parentId) expanded.add(parentId);
|
|
toast('Folder created', 'ok');
|
|
await loadTree(); renderTab();
|
|
} catch (e) { err.textContent = e.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// keep the calculated validity in step with the chosen date
|
|
const d = document.getElementById('nf_date');
|
|
d.oninput = () => {
|
|
document.getElementById('nf_valid').value = fmtDate(plus30(toIsoDate(d.value))) || '—';
|
|
};
|
|
}
|
|
|
|
function setFolderDate(id, name, current) {
|
|
const m = modal(`Incident date — ${name}`, `
|
|
<div class="field"><label>Incident date <span class="req-star">*</span></label>
|
|
<input id="sd_date" value="${esc(fmtDate(current) || '')}" placeholder="DD-MM-YYYY" maxlength="10"></div>
|
|
<div class="field"><label>Legal validity (calculated)</label>
|
|
<input id="sd_valid" value="${fmtDate(plus30(current)) || '—'}" disabled></div>
|
|
<span class="hint">Format DD-MM-YYYY. Legal Validity is always the incident date plus 30 days.</span>
|
|
<div class="err-line" id="sd_err"></div>`, {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Save date',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const typed = document.getElementById('sd_date').value.trim();
|
|
const recorded_date = toIsoDate(typed);
|
|
const err = document.getElementById('sd_err');
|
|
if (!typed) { err.textContent = 'An incident date is required.'; return false; }
|
|
if (!recorded_date) { err.textContent = 'Enter a valid date as DD-MM-YYYY.'; return false; }
|
|
(async () => {
|
|
try {
|
|
await api('/api/manage/folders/' + id, { method: 'PATCH', body: { recorded_date } });
|
|
m.close(); toast('Incident date saved', 'ok');
|
|
await loadTree(); renderTab();
|
|
} catch (e) { err.textContent = e.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const d = document.getElementById('sd_date');
|
|
d.oninput = () => {
|
|
document.getElementById('sd_valid').value = fmtDate(plus30(toIsoDate(d.value))) || '—';
|
|
};
|
|
}
|
|
|
|
function todayStr() { return new Date().toISOString().slice(0, 10); }
|
|
|
|
// Mirror of the server-side calculation, for live feedback in the form.
|
|
function plus30(dateStr) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr || '')) return '';
|
|
const [y, mo, da] = dateStr.split('-').map(Number);
|
|
const dt = new Date(Date.UTC(y, mo - 1, da));
|
|
dt.setUTCDate(dt.getUTCDate() + 30);
|
|
return dt.toISOString().slice(0, 10);
|
|
}
|
|
function renameFolder(id, name) {
|
|
const m = prompt2('Rename folder', [{ key: 'name', label: 'Folder name', value: name }], async (v) => {
|
|
await api('/api/manage/folders/' + id, { method: 'PATCH', body: { name: v.name } });
|
|
m.close(); toast('Renamed', 'ok'); await loadTree();
|
|
});
|
|
}
|
|
function moveFolder(id) {
|
|
modal('Move folder', `<div class="field"><label>Move into</label>
|
|
<select id="dest">${folderOptions(null, id)}</select></div>`, {
|
|
buttons: [{ label: 'Cancel' }, { label: 'Move', className: 'btn-primary', onClick: async (back) => {
|
|
try {
|
|
await api('/api/manage/folders/' + id, { method: 'PATCH', body: { parent_id: back.querySelector('#dest').value || null } });
|
|
back.remove(); toast('Moved', 'ok'); await loadTree();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
return false;
|
|
} }],
|
|
});
|
|
}
|
|
function renameFile(id, name) {
|
|
const m = prompt2('Rename file', [{ key: 'name', label: 'File name', value: name }], async (v) => {
|
|
await api('/api/manage/files/' + id, { method: 'PATCH', body: { name: v.name } });
|
|
m.close(); toast('Renamed', 'ok'); await loadTree();
|
|
});
|
|
}
|
|
function moveFile(id) {
|
|
modal('Move file', `<div class="field"><label>Move into folder</label>
|
|
<select id="dest">${folderOptions(null)}</select></div>`, {
|
|
buttons: [{ label: 'Cancel' }, { label: 'Move', className: 'btn-primary', onClick: async (back) => {
|
|
await api('/api/manage/files/' + id, { method: 'PATCH', body: { folder_id: back.querySelector('#dest').value || null } });
|
|
back.remove(); toast('Moved', 'ok'); await loadTree();
|
|
return false;
|
|
} }],
|
|
});
|
|
}
|
|
function del(type, id, name) {
|
|
modal(`Delete ${type}`, `<p>Delete <strong>${esc(name)}</strong>?${type === 'folder' ? ' All files and sub-folders inside it will also be removed.' : ''} This cannot be undone.</p>`, {
|
|
buttons: [{ label: 'Cancel' }, { label: 'Delete', className: 'btn-danger', onClick: async (back) => {
|
|
await api(`/api/manage/${type}s/${id}`, { method: 'DELETE' });
|
|
back.remove(); toast('Deleted', 'ok'); await loadTree();
|
|
} }],
|
|
});
|
|
}
|
|
function uploadTo(folderId) {
|
|
const m = modal('Upload files', `
|
|
<div class="field">
|
|
<label>Destination</label>
|
|
<select id="dest">${folderOptions(folderId)}</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Choose files</label>
|
|
<input type="file" id="files" multiple>
|
|
</div>
|
|
<div class="upload-progress" id="upwrap" hidden>
|
|
<div class="upload-bar"><span id="upbar" style="width:0%"></span></div>
|
|
<div class="hint" id="upstat"></div>
|
|
</div>
|
|
<div class="err-line" id="uphint"></div>`, {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{ label: 'Upload', className: 'btn-primary' },
|
|
],
|
|
});
|
|
|
|
const back = m.el;
|
|
const btns = back.querySelectorAll('.modal-foot .btn');
|
|
const cancelBtn = btns[0];
|
|
const uploadBtn = btns[1];
|
|
const inp = back.querySelector('#files');
|
|
const hint = back.querySelector('#uphint');
|
|
const wrap = back.querySelector('#upwrap');
|
|
const bar = back.querySelector('#upbar');
|
|
const stat = back.querySelector('#upstat');
|
|
|
|
// The default modal buttons close on click; wire our own behaviour instead.
|
|
cancelBtn.onclick = () => m.close();
|
|
uploadBtn.onclick = async () => {
|
|
hint.textContent = '';
|
|
if (!inp.files.length) { hint.textContent = 'Select at least one file.'; return; }
|
|
|
|
const fd = new FormData();
|
|
fd.append('folder_id', back.querySelector('#dest').value || '');
|
|
let totalBytes = 0;
|
|
for (const f of inp.files) { fd.append('files', f); totalBytes += f.size; }
|
|
|
|
// Lock the controls while the upload runs.
|
|
uploadBtn.disabled = true; cancelBtn.disabled = true; inp.disabled = true;
|
|
uploadBtn.textContent = 'Uploading…';
|
|
wrap.hidden = false;
|
|
const count = inp.files.length;
|
|
stat.textContent = `Preparing ${count} file${count === 1 ? '' : 's'} (${fmtBytes(totalBytes)})…`;
|
|
|
|
try {
|
|
await apiUpload('/api/manage/files', fd, (pct) => {
|
|
if (pct === null) { bar.style.width = '100%'; stat.textContent = 'Uploading…'; return; }
|
|
bar.style.width = pct + '%';
|
|
stat.textContent = pct < 100
|
|
? `Uploading… ${pct}% of ${fmtBytes(totalBytes)}`
|
|
: 'Finishing…';
|
|
});
|
|
bar.style.width = '100%';
|
|
m.close();
|
|
toast(`Uploaded ${count} file${count === 1 ? '' : 's'}`, 'ok');
|
|
await loadTree(); renderTab();
|
|
} catch (e) {
|
|
hint.textContent = e.message;
|
|
wrap.hidden = true;
|
|
uploadBtn.disabled = false; cancelBtn.disabled = false; inp.disabled = false;
|
|
uploadBtn.textContent = 'Upload';
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---------------- REQUESTS ----------------
|
|
// Notifications silently pile up when no mail server is set, so say so loudly.
|
|
function mailWarning() {
|
|
if (mailCfg && mailCfg.host) return '';
|
|
return `
|
|
<div class="warn-banner">
|
|
<strong>No mail server is configured.</strong>
|
|
Administrators are <em>not</em> receiving the “pending approval” emails —
|
|
each one is recorded as <strong>queued</strong> in the Email Log instead.
|
|
<a href="#" id="goMailCfg">Configure the mail server</a>
|
|
</div>`;
|
|
}
|
|
|
|
function bindMailWarning() {
|
|
const a = document.getElementById('goMailCfg');
|
|
if (a) a.onclick = (e) => { e.preventDefault(); tab = 'mailcfg'; renderTabs(); renderTab(); };
|
|
}
|
|
|
|
// Shows how long an approved request lasts, and whether it has lapsed.
|
|
function validityCell(r) {
|
|
if (r.status !== 'approved') return '';
|
|
if (!r.access_expires_at) return '<div class="hint">Valid forever</div>';
|
|
const lapsed = Date.parse(String(r.access_expires_at).replace(' ', 'T') + 'Z') <= Date.now();
|
|
const label = (ACCESS_DURATIONS.find((d) => d.key === r.access_duration) || {}).label || 'Timed';
|
|
return `<div class="hint">${esc(label)} — ${lapsed ? 'expired' : 'until'} ${
|
|
esc(fmtDateTime(r.access_expires_at))}</div>`;
|
|
}
|
|
|
|
function requestsView() {
|
|
if (!requests.length) return mailWarning() + `<div class="card"><div class="empty">${ICON.mail}<div>No access requests yet.</div></div></div>`;
|
|
const rows = requests.map((r) => `
|
|
<tr>
|
|
<td>${esc(r.username)}<div class="hint">${esc(r.email)}</div></td>
|
|
<td>${esc(r.target_type)}: <strong>${esc(r.target_name)}</strong></td>
|
|
<td class="mono">${esc(fmtDateTime(r.created_at))}</td>
|
|
<td><span class="badge badge-${r.status}">${r.status}</span>${validityCell(r)}</td>
|
|
<td>${r.status === 'pending' ? `
|
|
<button class="btn btn-sm btn-primary" data-appr="${r.id}">Approve</button>
|
|
<button class="btn btn-sm btn-danger" data-deny="${r.id}">Deny</button>` : '—'}</td>
|
|
</tr>`).join('');
|
|
return mailWarning() + `<div class="card"><div class="card-head"><h2>Access requests</h2>
|
|
<span class="hint">Every new request emails all administrators; approving notifies the requester.</span></div>
|
|
<table><thead><tr><th>User</th><th>Requested</th><th>When</th><th>Status</th><th>Action</th></tr></thead>
|
|
<tbody>${rows}</tbody></table></div>`;
|
|
}
|
|
function bindRequests() {
|
|
bindMailWarning();
|
|
document.querySelectorAll('[data-appr]').forEach((b) => b.onclick = () => decide(b.dataset.appr, 'approve'));
|
|
document.querySelectorAll('[data-deny]').forEach((b) => b.onclick = () => decide(b.dataset.deny, 'deny'));
|
|
}
|
|
async function decide(id, action) {
|
|
if (action === 'deny') {
|
|
try {
|
|
await api(`/api/data/requests/${id}/deny`, { method: 'POST' });
|
|
toast('Request denied', 'ok');
|
|
await loadRequests(); renderTabs(); renderTab();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
return;
|
|
}
|
|
|
|
// Approving requires choosing how long the access lasts. The countdown the
|
|
// user sees starts from the moment this is confirmed.
|
|
const req = requests.find((r) => String(r.id) === String(id)) || {};
|
|
const opts = ACCESS_DURATIONS.map((d, i) => `
|
|
<label class="radio-row">
|
|
<input type="radio" name="dur" value="${d.key}" ${i === 0 ? 'checked' : ''}>
|
|
<span>${d.label}</span>
|
|
</label>`).join('');
|
|
|
|
const m = modal('Approve access', `
|
|
<p class="hint" style="margin:0 0 12px">
|
|
Granting <strong>${esc(req.username || 'the user')}</strong> access to
|
|
${esc(req.target_type || 'item')} <strong>${esc(req.target_name || '')}</strong>.
|
|
The validity is counted from now.
|
|
</p>
|
|
<div class="radio-group">${opts}</div>
|
|
<div class="err-line" id="ap_err"></div>`, {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Approve',
|
|
className: 'btn-primary',
|
|
onClick: (back) => {
|
|
const sel = back.querySelector('input[name="dur"]:checked');
|
|
if (!sel) {
|
|
back.querySelector('#ap_err').textContent = 'Choose a validity period.';
|
|
return false;
|
|
}
|
|
(async () => {
|
|
try {
|
|
await api(`/api/data/requests/${id}/approve`, {
|
|
method: 'POST', body: { duration: sel.value },
|
|
});
|
|
m.close();
|
|
toast('Approved — user notified', 'ok');
|
|
await loadRequests(); renderTabs(); renderTab();
|
|
} catch (e) { back.querySelector('#ap_err').textContent = e.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// ---------------- TEMPLATES ----------------
|
|
const TPL_LABELS = {
|
|
request_to_admin: 'Request received (to admins)',
|
|
approval_to_user: 'Request approved (to user)',
|
|
denial_to_user: 'Request declined (to user)',
|
|
};
|
|
const PLACEHOLDERS = ['username', 'email', 'target_type', 'target_name', 'created_at'];
|
|
|
|
function templatesView() {
|
|
const cards = templates.map((t) => `
|
|
<div class="card">
|
|
<div class="card-head"><h2>${esc(TPL_LABELS[t.key] || t.key)}</h2></div>
|
|
<div class="card-body">
|
|
<div class="field">
|
|
<label>Subject</label>
|
|
<input type="text" data-tk="${t.key}" data-f="subject" value="${esc(t.subject)}">
|
|
</div>
|
|
<div class="field">
|
|
<label>Body (HTML) — insert images from the Brand Images tab</label>
|
|
<textarea data-tk="${t.key}" data-f="body">${esc(t.body_html)}</textarea>
|
|
<div class="placeholders">
|
|
${PLACEHOLDERS.map((p) => `<code data-ins="${t.key}" data-ph="{{${p}}}">{{${p}}}</code>`).join('')}
|
|
${images.map((i) => `<code data-ins="${t.key}" data-ph='<img src="${location.origin}${i.url}" alt="${esc(i.label)}" style="max-width:200px">'>🖼 ${esc(i.label)}</code>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<button class="btn btn-primary" data-save="${t.key}">Save template</button>
|
|
<button class="btn" data-prev="${t.key}">Preview</button>
|
|
<button class="btn" data-restore="${t.key}"${t.is_default ? ' disabled title="Already the default"' : ''}>Restore default</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join('');
|
|
return `<p class="hint" style="margin-bottom:14px">Use placeholders like <code>{{username}}</code> — they are filled in automatically when the email is sent. The footer is added to every email.</p>${cards}`;
|
|
}
|
|
|
|
function bindTemplates() {
|
|
document.querySelectorAll('[data-ins]').forEach((c) => c.onclick = () => {
|
|
const ta = document.querySelector(`textarea[data-tk="${c.dataset.ins}"]`);
|
|
const ph = c.getAttribute('data-ph');
|
|
const start = ta.selectionStart || ta.value.length;
|
|
ta.value = ta.value.slice(0, start) + ph + ta.value.slice(ta.selectionEnd || start);
|
|
ta.focus();
|
|
});
|
|
document.querySelectorAll('[data-save]').forEach((b) => b.onclick = async () => {
|
|
const key = b.dataset.save;
|
|
const subject = document.querySelector(`input[data-tk="${key}"][data-f="subject"]`).value;
|
|
const body_html = document.querySelector(`textarea[data-tk="${key}"]`).value;
|
|
try {
|
|
await api('/settings/templates/' + key, { method: 'PUT', body: { subject, body_html } });
|
|
toast('Template saved', 'ok'); await loadTemplates();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
});
|
|
document.querySelectorAll('[data-prev]').forEach((b) => b.onclick = async () => {
|
|
const key = b.dataset.prev;
|
|
const subject = document.querySelector(`input[data-tk="${key}"][data-f="subject"]`).value;
|
|
const body_html = document.querySelector(`textarea[data-tk="${key}"]`).value;
|
|
const r = await api('/settings/templates/' + key + '/preview', { method: 'POST', body: { subject, body_html } });
|
|
modal('Email preview', `<p><strong>Subject:</strong> ${esc(r.subject)}</p>
|
|
<div style="border:1px solid var(--line);border-radius:10px;padding:16px;margin-top:10px;background:#fff">${r.html}</div>`, { lg: true });
|
|
});
|
|
document.querySelectorAll('[data-restore]').forEach((b) => b.onclick = async () => {
|
|
const key = b.dataset.restore;
|
|
if (!confirm('Replace this template with the default that ships with this release?\n\nYour current wording will be lost.')) return;
|
|
try {
|
|
await api('/settings/templates/' + key + '/restore', { method: 'POST' });
|
|
toast('Template restored to default', 'ok');
|
|
await loadTemplates(); renderTab();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
});
|
|
}
|
|
|
|
// ---------------- IMAGES ----------------
|
|
function imagesView() {
|
|
const grid = images.length ? `<div class="img-grid">${images.map((i) => `
|
|
<div class="img-card">
|
|
<img src="${i.url}" alt="${esc(i.label)}">
|
|
<div class="cap"><span class="lbl" title="${esc(i.label)}">${esc(i.label)}</span>
|
|
<button class="btn btn-sm btn-ghost" data-delimg="${i.id}" title="Delete">${ICON.trash}</button></div>
|
|
</div>`).join('')}</div>` : `<div class="empty">${ICON.file}<div>No images uploaded yet.</div></div>`;
|
|
return `<div class="card">
|
|
<div class="card-head"><h2>Brand images</h2></div>
|
|
<div class="card-body">
|
|
<div class="toolbar">
|
|
<input type="file" id="imgFile" accept="image/*" style="max-width:280px">
|
|
<input type="text" id="imgLabel" placeholder="Label (e.g. Company logo)" style="max-width:240px">
|
|
<button class="btn btn-primary" id="imgUp">${ICON.upload} Upload image</button>
|
|
</div>
|
|
<p class="hint" style="margin-bottom:16px">Uploaded images can be inserted into any email template from the Email Templates tab.</p>
|
|
${grid}
|
|
</div></div>`;
|
|
}
|
|
function bindImages() {
|
|
document.getElementById('imgUp').onclick = async () => {
|
|
const f = document.getElementById('imgFile').files[0];
|
|
if (!f) { toast('Choose an image first', 'err'); return; }
|
|
const fd = new FormData();
|
|
fd.append('image', f);
|
|
fd.append('label', document.getElementById('imgLabel').value || f.name);
|
|
try { await api('/settings/images', { method: 'POST', body: fd }); toast('Image uploaded', 'ok'); await loadImages(); renderTab(); }
|
|
catch (e) { toast(e.message, 'err'); }
|
|
};
|
|
document.querySelectorAll('[data-delimg]').forEach((b) => b.onclick = async () => {
|
|
await api('/settings/images/' + b.dataset.delimg, { method: 'DELETE' });
|
|
toast('Image deleted', 'ok'); await loadImages(); renderTab();
|
|
});
|
|
}
|
|
|
|
// ---------------- USERS ----------------
|
|
function usersView() {
|
|
const rows = users.map((u) => `
|
|
<tr>
|
|
<td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="badge">you</span>' : ''}</td>
|
|
<td>${u.email_missing
|
|
? '<span class="badge badge-failed">missing — required</span>'
|
|
: esc(u.email)}</td>
|
|
<td><span class="badge ${u.role === 'admin' ? 'badge-admin' : ''}">${u.role}</span></td>
|
|
<td>${u.mfa_enabled ? 'Enabled' : '—'}</td>
|
|
<td class="row-actions">
|
|
<button class="btn btn-sm" data-edituser="${u.id}">Edit</button>
|
|
${u.mfa_enabled ? `<button class="btn btn-sm" data-resetmfa="${u.id}">Reset MFA</button>` : ''}
|
|
${u.id === me.id ? '' : `<button class="btn btn-sm btn-danger" data-deluser="${u.id}">Delete</button>`}
|
|
</td>
|
|
</tr>`).join('');
|
|
|
|
const missing = users.filter((u) => u.email_missing).length;
|
|
const missingBanner = missing ? `
|
|
<div class="warn-banner">
|
|
<strong>${missing} account(s) have no email address.</strong>
|
|
Email is required — these users cannot receive approval notifications.
|
|
Use <em>Edit</em> to add one.
|
|
</div>` : '';
|
|
|
|
return `
|
|
${missingBanner}
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<h3>Accounts</h3>
|
|
<button class="btn btn-primary btn-sm" id="newUserBtn">New user</button>
|
|
</div>
|
|
<p class="hint" style="margin:0 0 12px">
|
|
Create accounts for the people who need to browse and request data.
|
|
Administrators can additionally manage data, approve requests, and read the logs.
|
|
</p>
|
|
<table>
|
|
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>2FA</th><th></th></tr></thead>
|
|
<tbody>${rows || '<tr><td colspan="5" class="muted">No users yet.</td></tr>'}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
function userFormHtml(u) {
|
|
return `
|
|
<div class="field"><label>Username</label>
|
|
<input id="u_name" value="${u ? esc(u.username) : ''}" ${u ? 'disabled' : ''} placeholder="jsilva"></div>
|
|
<div class="field"><label>Email <span class="req-star">*</span></label>
|
|
<input id="u_email" type="email" required value="${u ? esc(u.email) : ''}" placeholder="person@yourdomain.com">
|
|
<span class="hint">Required. Approval notifications and system messages are sent here.</span></div>
|
|
<div class="field"><label>Role</label>
|
|
<select id="u_role">
|
|
<option value="user" ${u && u.role === 'user' ? 'selected' : ''}>User — can view and request data</option>
|
|
<option value="admin" ${u && u.role === 'admin' ? 'selected' : ''}>Administrator — full access</option>
|
|
</select></div>
|
|
<div class="field"><label>${u ? 'New password (leave blank to keep current)' : 'Password'}</label>
|
|
<input type="password" id="u_pass" autocomplete="new-password"></div>
|
|
<span class="hint">At least 10 characters, including a letter and a number.</span>
|
|
<div class="err-line" id="u_err"></div>`;
|
|
}
|
|
|
|
function bindUsers() {
|
|
document.getElementById('newUserBtn').onclick = () => {
|
|
const m = modal('New user', userFormHtml(null), {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Create user',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = {
|
|
username: document.getElementById('u_name').value.trim(),
|
|
email: document.getElementById('u_email').value.trim(),
|
|
role: document.getElementById('u_role').value,
|
|
password: document.getElementById('u_pass').value,
|
|
};
|
|
if (!body.email) {
|
|
document.getElementById('u_err').textContent = 'An email address is required.';
|
|
return false;
|
|
}
|
|
(async () => {
|
|
try {
|
|
await api('/api/users', { method: 'POST', body });
|
|
m.close();
|
|
toast('User created', 'ok');
|
|
await loadUsers(); renderTab();
|
|
} catch (e) { document.getElementById('u_err').textContent = e.message; }
|
|
})();
|
|
return false; // keep open until the request succeeds
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
document.querySelectorAll('[data-edituser]').forEach((b) => b.onclick = () => {
|
|
const u = users.find((x) => x.id == b.dataset.edituser);
|
|
const m = modal(`Edit ${u.username}`, userFormHtml(u), {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Save changes',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = {
|
|
email: document.getElementById('u_email').value.trim(),
|
|
role: document.getElementById('u_role').value,
|
|
};
|
|
if (!body.email) {
|
|
document.getElementById('u_err').textContent = 'An email address is required.';
|
|
return false;
|
|
}
|
|
const pw = document.getElementById('u_pass').value;
|
|
if (pw) body.password = pw;
|
|
(async () => {
|
|
try {
|
|
await api('/api/users/' + u.id, { method: 'PATCH', body });
|
|
m.close();
|
|
toast('User updated', 'ok');
|
|
await loadUsers(); renderTab();
|
|
} catch (e) { document.getElementById('u_err').textContent = e.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-resetmfa]').forEach((b) => b.onclick = async () => {
|
|
const u = users.find((x) => x.id == b.dataset.resetmfa);
|
|
if (!confirm(`Turn off two-factor authentication for "${u.username}"?\n\nUse this when someone has lost their authenticator app. They can set it up again from My Account.`)) return;
|
|
try {
|
|
await api(`/api/users/${u.id}/mfa/reset`, { method: 'POST' });
|
|
toast('Two-factor authentication reset', 'ok');
|
|
await loadUsers(); renderTab();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
});
|
|
|
|
document.querySelectorAll('[data-deluser]').forEach((b) => b.onclick = async () => {
|
|
const u = users.find((x) => x.id == b.dataset.deluser);
|
|
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
|
|
try {
|
|
await api('/api/users/' + u.id, { method: 'DELETE' });
|
|
toast('User deleted', 'ok');
|
|
await loadUsers(); renderTab();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
});
|
|
}
|
|
|
|
// ---------------- VERSION CONTROL ----------------
|
|
function versionsView() {
|
|
const rows = versions.map((e) => `
|
|
<tr>
|
|
<td><strong>${esc(e.version)}</strong></td>
|
|
<td>${esc(e.title || '—')}</td>
|
|
<td>${esc(fmtDate(e.released_on) || '—')}</td>
|
|
<td class="notes-cell">${esc((e.notes || '').slice(0, 80))}${(e.notes || '').length > 80 ? '…' : ''}</td>
|
|
<td>${e.sort_order}</td>
|
|
<td class="row-actions">
|
|
<button class="btn btn-sm" data-editver="${e.id}">Edit</button>
|
|
<button class="btn btn-sm btn-danger" data-delver="${e.id}">Delete</button>
|
|
</td>
|
|
</tr>`).join('');
|
|
|
|
return `
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<h3>Version Control entries</h3>
|
|
<button class="btn btn-primary btn-sm" id="newVerBtn">New entry</button>
|
|
</div>
|
|
<p class="hint" style="margin:0 0 12px">
|
|
These entries are what everyone sees on the <strong>Version Control</strong> page.
|
|
Higher “order” values appear first.
|
|
</p>
|
|
<table>
|
|
<thead><tr><th>Version</th><th>Title</th><th>Released</th><th>Notes</th><th>Order</th><th></th></tr></thead>
|
|
<tbody>${rows || '<tr><td colspan="6" class="muted">No entries yet.</td></tr>'}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
function verFormHtml(e) {
|
|
return `
|
|
<div class="field"><label>Version</label>
|
|
<input id="v_version" value="${e ? esc(e.version) : ''}" placeholder="1.3"></div>
|
|
<div class="field"><label>Title</label>
|
|
<input id="v_title" value="${e ? esc(e.title || '') : ''}" placeholder="Folder-level access control"></div>
|
|
<div class="field"><label>Release date</label>
|
|
<input id="v_date" value="${e ? esc(fmtDate(e.released_on) || '') : ''}" placeholder="DD-MM-YYYY" maxlength="10"></div>
|
|
<div class="field"><label>Notes</label>
|
|
<textarea id="v_notes" rows="6" placeholder="What changed in this release…">${e ? esc(e.notes || '') : ''}</textarea></div>
|
|
<div class="field"><label>Display order</label>
|
|
<input id="v_sort" type="number" value="${e ? Number(e.sort_order) : 0}"></div>
|
|
<span class="hint">Only the version is required. Entries are listed by order, then date.</span>
|
|
<div class="err-line" id="v_err"></div>`;
|
|
}
|
|
|
|
function verPayload() {
|
|
return {
|
|
version: document.getElementById('v_version').value.trim(),
|
|
title: document.getElementById('v_title').value.trim(),
|
|
released_on: toIsoDate(document.getElementById('v_date').value),
|
|
notes: document.getElementById('v_notes').value,
|
|
sort_order: Number(document.getElementById('v_sort').value) || 0,
|
|
};
|
|
}
|
|
|
|
function bindVersions() {
|
|
document.getElementById('newVerBtn').onclick = () => {
|
|
const m = modal('New version entry', verFormHtml(null), {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Add entry',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = verPayload();
|
|
(async () => {
|
|
try {
|
|
await api('/api/versions', { method: 'POST', body });
|
|
m.close();
|
|
toast('Version entry added', 'ok');
|
|
await loadVersions(); renderTab();
|
|
} catch (err) { document.getElementById('v_err').textContent = err.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
document.querySelectorAll('[data-editver]').forEach((b) => b.onclick = () => {
|
|
const e = versions.find((x) => x.id == b.dataset.editver);
|
|
const m = modal(`Edit version ${e.version}`, verFormHtml(e), {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Save changes',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = verPayload();
|
|
(async () => {
|
|
try {
|
|
await api('/api/versions/' + e.id, { method: 'PATCH', body });
|
|
m.close();
|
|
toast('Version entry updated', 'ok');
|
|
await loadVersions(); renderTab();
|
|
} catch (err) { document.getElementById('v_err').textContent = err.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-delver]').forEach((b) => b.onclick = async () => {
|
|
const e = versions.find((x) => x.id == b.dataset.delver);
|
|
if (!confirm(`Delete version entry "${e.version}"? This cannot be undone.`)) return;
|
|
try {
|
|
await api('/api/versions/' + e.id, { method: 'DELETE' });
|
|
toast('Version entry deleted', 'ok');
|
|
await loadVersions(); renderTab();
|
|
} catch (err) { toast(err.message, 'err'); }
|
|
});
|
|
}
|
|
|
|
// ---------------- EMAIL SERVER CONFIGURATION ----------------
|
|
function mailCfgView() {
|
|
const c = mailCfg || {};
|
|
const configured = !!c.host;
|
|
const srcNote = c.source === 'environment'
|
|
? 'Currently taken from environment variables. Saving here overrides them.'
|
|
: (c.source === 'settings' ? 'Configured here, in the application.' : 'No mail server configured yet.');
|
|
|
|
return `
|
|
<div class="card">
|
|
<div class="card-head"><h3>Outgoing mail server (SMTP)</h3></div>
|
|
<div class="cfg-status">
|
|
<span class="dot ${configured ? 'on' : 'off'}"></span>
|
|
<span>${configured ? esc(c.host) + ':' + c.port : 'Not configured'}</span>
|
|
<span class="hint" style="margin-left:8px">${srcNote}</span>
|
|
</div>
|
|
<p class="hint" style="margin:0 0 14px">
|
|
While no server is configured the system still works: messages are marked
|
|
<strong>queued</strong> in the Email Log instead of being lost.
|
|
</p>
|
|
<div class="cfg-grid">
|
|
<div class="field"><label>Server host</label>
|
|
<input id="m_host" value="${esc(c.host || '')}" placeholder="smtp.yourprovider.com"></div>
|
|
<div class="field"><label>Port</label>
|
|
<input id="m_port" type="number" value="${Number(c.port) || 587}"></div>
|
|
<div class="field"><label>Username</label>
|
|
<input id="m_user" value="${esc(c.user || '')}" placeholder="apikey or account name"></div>
|
|
<div class="field"><label>Password</label>
|
|
<input id="m_pass" type="password" value="${esc(c.pass || '')}" autocomplete="new-password"></div>
|
|
<div class="field wide"><label>From address</label>
|
|
<input id="m_from" value="${esc(c.from || '')}" placeholder="Infosec <no-reply@yourdomain.com>"></div>
|
|
<div class="field wide"><label>Portal address (used for links in emails)</label>
|
|
<input id="m_base" value="${esc(c.base_url || '')}" placeholder="https://infosec.yourdomain.com">
|
|
<span class="hint">Optional. Sets where the “Review pending requests” button in notification emails points.</span></div>
|
|
<div class="field wide">
|
|
<label style="display:flex;align-items:center;gap:9px;font-weight:500">
|
|
<input type="checkbox" id="m_secure" ${c.secure ? 'checked' : ''} style="width:auto">
|
|
Use implicit TLS (usually port 465; leave off for STARTTLS on 587)
|
|
</label>
|
|
</div>
|
|
<div class="field wide">
|
|
<label style="display:flex;align-items:center;gap:9px;font-weight:500">
|
|
<input type="checkbox" id="m_reject" ${c.reject_unauthorized === false ? '' : 'checked'} style="width:auto">
|
|
Verify the server's TLS certificate (turn off only for self-signed internal servers)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="row-actions" style="justify-content:flex-start;margin-top:10px">
|
|
<button class="btn btn-primary" id="mSaveBtn">Save settings</button>
|
|
<button class="btn" id="mTestBtn">Test connection</button>
|
|
<button class="btn" id="mSendBtn">Send test message</button>
|
|
</div>
|
|
<div class="err-line" id="m_err"></div>
|
|
|
|
<div class="divider" style="margin:22px 0"></div>
|
|
|
|
<h3 style="margin:0 0 4px">Copy sent mail to the Sent folder (IMAP)</h3>
|
|
<p class="hint" style="margin:0 0 14px">
|
|
Optional. When set, a copy of every message the platform sends is placed
|
|
in your mail server's <strong>Sent</strong> folder, so sent mail is
|
|
visible in your normal mail client. Leave the host blank to turn this off.
|
|
Credentials default to the SMTP username and password above.
|
|
</p>
|
|
<div class="cfg-grid">
|
|
<div class="field"><label>IMAP host</label>
|
|
<input id="i_host" value="${esc(c.imap_host || '')}" placeholder="imap.yourprovider.com"></div>
|
|
<div class="field"><label>Port</label>
|
|
<input id="i_port" type="number" value="${Number(c.imap_port) || 993}"></div>
|
|
<div class="field"><label>Username <span class="hint">(blank = use SMTP)</span></label>
|
|
<input id="i_user" value="${esc(c.imap_user || '')}" placeholder="defaults to SMTP username"></div>
|
|
<div class="field"><label>Password <span class="hint">(blank = use SMTP)</span></label>
|
|
<input id="i_pass" type="password" value="${esc(c.imap_pass || '')}" autocomplete="new-password"></div>
|
|
<div class="field wide"><label>Sent folder name <span class="hint">(optional)</span></label>
|
|
<input id="i_folder" value="${esc(c.imap_sent_folder || '')}" placeholder="Sent — leave blank to detect automatically">
|
|
<span class="hint">If blank, the server's own Sent mailbox is used, falling back to common names.</span></div>
|
|
<div class="field wide">
|
|
<label style="display:flex;align-items:center;gap:9px;font-weight:500">
|
|
<input type="checkbox" id="i_secure" ${c.imap_secure === false ? '' : 'checked'} style="width:auto">
|
|
Use implicit TLS (usually port 993)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="row-actions" style="justify-content:flex-start;margin-top:10px">
|
|
<button class="btn" id="iTestBtn">Test IMAP</button>
|
|
</div>
|
|
<div class="err-line" id="i_err"></div>
|
|
</div>`;
|
|
}
|
|
|
|
function mailCfgPayload() {
|
|
return {
|
|
host: document.getElementById('m_host').value.trim(),
|
|
port: Number(document.getElementById('m_port').value) || 587,
|
|
user: document.getElementById('m_user').value.trim(),
|
|
pass: document.getElementById('m_pass').value,
|
|
from: document.getElementById('m_from').value.trim(),
|
|
base_url: document.getElementById('m_base').value.trim(),
|
|
secure: document.getElementById('m_secure').checked,
|
|
reject_unauthorized: document.getElementById('m_reject').checked,
|
|
imap_host: document.getElementById('i_host').value.trim(),
|
|
imap_port: Number(document.getElementById('i_port').value) || 993,
|
|
imap_user: document.getElementById('i_user').value.trim(),
|
|
imap_pass: document.getElementById('i_pass').value,
|
|
imap_sent_folder: document.getElementById('i_folder').value.trim(),
|
|
imap_secure: document.getElementById('i_secure').checked,
|
|
};
|
|
}
|
|
|
|
function bindMailCfg() {
|
|
document.getElementById('mSaveBtn').onclick = async () => {
|
|
document.getElementById('m_err').textContent = '';
|
|
try {
|
|
await api('/api/mail/config', { method: 'PUT', body: mailCfgPayload() });
|
|
toast('Mail server settings saved', 'ok');
|
|
await loadMailCfg(); renderTab();
|
|
} catch (e) { document.getElementById('m_err').textContent = e.message; }
|
|
};
|
|
|
|
document.getElementById('mTestBtn').onclick = async (ev) => {
|
|
const b = ev.currentTarget;
|
|
document.getElementById('m_err').textContent = '';
|
|
b.disabled = true; b.textContent = 'Testing…';
|
|
try {
|
|
const r = await api('/api/mail/test', { method: 'POST', body: mailCfgPayload() });
|
|
toast(r.message || 'Connection successful', 'ok');
|
|
} catch (e) { document.getElementById('m_err').textContent = e.message; }
|
|
b.disabled = false; b.textContent = 'Test connection';
|
|
};
|
|
|
|
document.getElementById('iTestBtn').onclick = async (ev) => {
|
|
const b = ev.currentTarget;
|
|
document.getElementById('i_err').textContent = '';
|
|
const p = mailCfgPayload();
|
|
if (!p.imap_host) { document.getElementById('i_err').textContent = 'Enter an IMAP host first.'; return; }
|
|
b.disabled = true; b.textContent = 'Testing…';
|
|
try {
|
|
const r = await api('/api/mail/test-imap', { method: 'POST', body: p });
|
|
toast(r.message || 'IMAP connection successful', 'ok');
|
|
} catch (e) { document.getElementById('i_err').textContent = e.message; }
|
|
b.disabled = false; b.textContent = 'Test IMAP';
|
|
};
|
|
|
|
document.getElementById('mSendBtn').onclick = () => {
|
|
const m = modal('Send a test message', `
|
|
<div class="field"><label>Send to</label>
|
|
<input id="t_to" value="${esc(me.email)}"></div>
|
|
<span class="hint">Uses the saved settings, and appears in the Email Log.</span>
|
|
<div class="err-line" id="t_err"></div>`, {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Send',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const to = document.getElementById('t_to').value.trim();
|
|
(async () => {
|
|
try {
|
|
const r = await api('/api/mail/test-send', { method: 'POST', body: { to } });
|
|
m.close();
|
|
toast(r.delivered ? 'Test message sent' : 'Queued — no mail server configured', r.delivered ? 'ok' : 'err');
|
|
await loadMailLog(); if (tab === 'maillog') renderTab();
|
|
} catch (e) { document.getElementById('t_err').textContent = e.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
}
|
|
|
|
// ---------------- EMAIL LOG ----------------
|
|
function mailLogView() {
|
|
const statusBadge = (s) => `<span class="badge badge-${esc(s)}">${esc(s)}</span>`;
|
|
const rows = mailLog.entries.map((e) => `
|
|
<tr>
|
|
<td class="mono">${esc(fmtDateTime(e.ts))}</td>
|
|
<td>${esc(e.direction)}</td>
|
|
<td>${statusBadge(e.status)}</td>
|
|
<td>${esc(e.to_addr || '—')}</td>
|
|
<td>${esc(e.subject || '—')}
|
|
<div class="mail-preview">${esc((e.body_preview || '').slice(0, 90))}${(e.body_preview || '').length > 90 ? '…' : ''}</div>
|
|
${e.error ? `<div class="mail-preview" style="color:#a8322e">${esc(e.error)}</div>` : ''}
|
|
</td>
|
|
<td>${esc(e.context || '—')}</td>
|
|
</tr>`).join('');
|
|
|
|
return `
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<h3>Email log <span class="hint">(${mailLog.total} message${mailLog.total === 1 ? '' : 's'})</span></h3>
|
|
<div class="row-actions" style="justify-content:flex-end">
|
|
<button class="btn btn-sm" id="mlExport">Export CSV</button>
|
|
<button class="btn btn-sm btn-primary" id="mlEmail">Email log</button>
|
|
</div>
|
|
</div>
|
|
<p class="hint" style="margin:0 0 12px">
|
|
Every message the system sends is recorded here, including messages queued
|
|
while no mail server is configured, and any delivery failures.
|
|
</p>
|
|
<div class="row-actions" style="justify-content:flex-start;margin-bottom:12px">
|
|
<input id="ml_q" placeholder="Search recipient, subject or body" value="${esc(mailFilter.q)}" style="max-width:280px">
|
|
<select id="ml_dir">
|
|
<option value="">All directions</option>
|
|
${(mailLog.directions || []).map((d) => `<option value="${esc(d)}" ${mailFilter.direction === d ? 'selected' : ''}>${esc(d)}</option>`).join('')}
|
|
</select>
|
|
<select id="ml_status">
|
|
<option value="">All statuses</option>
|
|
${(mailLog.statuses || []).map((d) => `<option value="${esc(d)}" ${mailFilter.status === d ? 'selected' : ''}>${esc(d)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Time</th><th>Direction</th><th>Status</th><th>To</th><th>Subject</th><th>Context</th></tr></thead>
|
|
<tbody>${rows || '<tr><td colspan="6" class="muted">No messages recorded yet.</td></tr>'}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
function mailLogQuery() {
|
|
const p = new URLSearchParams();
|
|
if (mailFilter.q) p.set('q', mailFilter.q);
|
|
if (mailFilter.direction) p.set('direction', mailFilter.direction);
|
|
if (mailFilter.status) p.set('status', mailFilter.status);
|
|
return p.toString();
|
|
}
|
|
|
|
function bindMailLog() {
|
|
const refresh = async () => { await loadMailLog(); renderTab(); };
|
|
const q = document.getElementById('ml_q');
|
|
q.onkeydown = (e) => { if (e.key === 'Enter') { mailFilter.q = q.value.trim(); refresh(); } };
|
|
document.getElementById('ml_dir').onchange = (e) => { mailFilter.direction = e.target.value; refresh(); };
|
|
document.getElementById('ml_status').onchange = (e) => { mailFilter.status = e.target.value; refresh(); };
|
|
|
|
document.getElementById('mlExport').onclick = () => {
|
|
location.href = '/api/mail/log/export?' + mailLogQuery();
|
|
};
|
|
|
|
document.getElementById('mlEmail').onclick = () => {
|
|
const m = modal('Email the mail log', `
|
|
<div class="field"><label>Send to</label>
|
|
<input id="ml_to" placeholder="Leave blank to send to all administrators"></div>
|
|
<span class="hint">The currently filtered entries are attached as a CSV file.</span>
|
|
<div class="err-line" id="ml_err"></div>`, {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Send',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = { ...mailFilter, to: document.getElementById('ml_to').value.trim() };
|
|
(async () => {
|
|
try {
|
|
const r = await api('/api/mail/log/email', { method: 'POST', body });
|
|
m.close();
|
|
toast(r.delivered ? `Sent ${r.count} entries` : `Queued ${r.count} entries (no mail server)`, r.delivered ? 'ok' : 'err');
|
|
await loadMailLog(); renderTab();
|
|
} catch (e) { document.getElementById('ml_err').textContent = e.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
}
|
|
|
|
// ---------------- LEGISLATION ----------------
|
|
function legislationView() {
|
|
const rows = legislation.map((e) => `
|
|
<tr>
|
|
<td>${esc(e.reference || '—')}</td>
|
|
<td><strong>${esc(e.title)}</strong></td>
|
|
<td>${esc(fmtDate(e.effective_date) || '—')}</td>
|
|
<td class="notes-cell">${esc((e.summary || '').slice(0, 70))}${(e.summary || '').length > 70 ? '…' : ''}</td>
|
|
<td>${e.sort_order}</td>
|
|
<td class="row-actions">
|
|
<button class="btn btn-sm" data-editleg="${e.id}">Edit</button>
|
|
<button class="btn btn-sm btn-danger" data-delleg="${e.id}">Delete</button>
|
|
</td>
|
|
</tr>`).join('');
|
|
|
|
return `
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<h3>Legislation entries</h3>
|
|
<button class="btn btn-primary btn-sm" id="newLegBtn">New entry</button>
|
|
</div>
|
|
<p class="hint" style="margin:0 0 12px">
|
|
These entries are what everyone sees on the <strong>Legislation</strong> page.
|
|
Higher “order” values appear first.
|
|
</p>
|
|
<table>
|
|
<thead><tr><th>Reference</th><th>Title</th><th>In force</th><th>Summary</th><th>Order</th><th></th></tr></thead>
|
|
<tbody>${rows || '<tr><td colspan="6" class="muted">No entries yet.</td></tr>'}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
function legFormHtml(e) {
|
|
return `
|
|
<div class="field"><label>Reference</label>
|
|
<input id="l_ref" value="${e ? esc(e.reference || '') : ''}" placeholder="e.g. GDPR Art. 6"></div>
|
|
<div class="field"><label>Title <span class="req-star">*</span></label>
|
|
<input id="l_title" value="${e ? esc(e.title || '') : ''}" placeholder="Lawful basis for processing"></div>
|
|
<div class="field"><label>In force from</label>
|
|
<input id="l_date" value="${e ? esc(fmtDate(e.effective_date) || '') : ''}" placeholder="DD-MM-YYYY" maxlength="10"></div>
|
|
<div class="field"><label>Summary</label>
|
|
<textarea id="l_sum" rows="6" placeholder="What this legislation requires…">${e ? esc(e.summary || '') : ''}</textarea></div>
|
|
<div class="field"><label>Link to full text</label>
|
|
<input id="l_url" value="${e ? esc(e.link_url || '') : ''}" placeholder="https://…"></div>
|
|
<div class="field"><label>Display order</label>
|
|
<input id="l_sort" type="number" value="${e ? Number(e.sort_order) : 0}"></div>
|
|
<div class="err-line" id="l_err"></div>`;
|
|
}
|
|
|
|
function legPayload() {
|
|
return {
|
|
reference: document.getElementById('l_ref').value.trim(),
|
|
title: document.getElementById('l_title').value.trim(),
|
|
effective_date: toIsoDate(document.getElementById('l_date').value),
|
|
summary: document.getElementById('l_sum').value,
|
|
link_url: document.getElementById('l_url').value.trim(),
|
|
sort_order: Number(document.getElementById('l_sort').value) || 0,
|
|
};
|
|
}
|
|
|
|
function bindLegislation() {
|
|
document.getElementById('newLegBtn').onclick = () => {
|
|
const m = modal('New legislation entry', legFormHtml(null), {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Add entry',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = legPayload();
|
|
if (!body.title) {
|
|
document.getElementById('l_err').textContent = 'A title is required.';
|
|
return false;
|
|
}
|
|
(async () => {
|
|
try {
|
|
await api('/api/legislation', { method: 'POST', body });
|
|
m.close(); toast('Legislation entry added', 'ok');
|
|
await loadLegislation(); renderTab();
|
|
} catch (err) { document.getElementById('l_err').textContent = err.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
document.querySelectorAll('[data-editleg]').forEach((b) => b.onclick = () => {
|
|
const e = legislation.find((x) => x.id == b.dataset.editleg);
|
|
const m = modal(`Edit ${e.title}`, legFormHtml(e), {
|
|
sticky: true,
|
|
buttons: [
|
|
{ label: 'Cancel' },
|
|
{
|
|
label: 'Save changes',
|
|
className: 'btn-primary',
|
|
onClick: () => {
|
|
const body = legPayload();
|
|
if (!body.title) {
|
|
document.getElementById('l_err').textContent = 'A title is required.';
|
|
return false;
|
|
}
|
|
(async () => {
|
|
try {
|
|
await api('/api/legislation/' + e.id, { method: 'PATCH', body });
|
|
m.close(); toast('Legislation entry updated', 'ok');
|
|
await loadLegislation(); renderTab();
|
|
} catch (err) { document.getElementById('l_err').textContent = err.message; }
|
|
})();
|
|
return false;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-delleg]').forEach((b) => b.onclick = async () => {
|
|
const e = legislation.find((x) => x.id == b.dataset.delleg);
|
|
if (!confirm(`Delete legislation entry "${e.title}"? This cannot be undone.`)) return;
|
|
try {
|
|
await api('/api/legislation/' + e.id, { method: 'DELETE' });
|
|
toast('Legislation entry deleted', 'ok');
|
|
await loadLegislation(); renderTab();
|
|
} catch (err) { toast(err.message, 'err'); }
|
|
});
|
|
}
|
|
|
|
// ---------------- wiring ----------------
|
|
// ---------------- DATA CREATION ----------------
|
|
function dcState() { return dcMode === 'document' ? dcDoc : dcTL; }
|
|
|
|
function renderDataCreate() {
|
|
document.getElementById('tabView').innerHTML = dataCreateView();
|
|
bindDataCreate();
|
|
}
|
|
|
|
function dataCreateView() {
|
|
const st = dcState();
|
|
const modes = `
|
|
<div class="dc-modes">
|
|
<button data-dcmode="document" class="${dcMode === 'document' ? 'active' : ''}">Document · text & images</button>
|
|
<button data-dcmode="timeline" class="${dcMode === 'timeline' ? 'active' : ''}">Timeline</button>
|
|
</div>`;
|
|
|
|
const dest = `
|
|
<div class="card"><div class="card-body">
|
|
<div class="dc-grid">
|
|
<div class="field">
|
|
<label>Destination folder</label>
|
|
<select id="dcFolder">${folderOptions(st.folder_id)}</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>File name</label>
|
|
<input type="text" id="dcName" value="${esc(st.name)}" placeholder="e.g. Incident report">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Heading shown inside the file (optional)</label>
|
|
<input type="text" id="dcTitle" value="${esc(st.title)}" placeholder="Defaults to no heading">
|
|
</div>
|
|
<p class="hint">Saved as a self-contained <strong>.html</strong> file in the folder you choose — it then behaves like any other file (viewable on View Data, subject to access).</p>
|
|
</div></div>`;
|
|
|
|
const body = dcMode === 'document' ? docBuilderView() : timelineBuilderView();
|
|
|
|
return `
|
|
${modes}
|
|
${dest}
|
|
${body}
|
|
<div class="dc-actions">
|
|
<button class="btn" id="dcPreview">Preview</button>
|
|
<button class="btn btn-primary" id="dcSave">${ICON.plus} Save to folder</button>
|
|
</div>`;
|
|
}
|
|
|
|
function docBuilderView() {
|
|
const blocks = dcDoc.blocks.map((b, i) => {
|
|
const tools = `
|
|
<div class="dc-tools">
|
|
<button class="btn btn-sm" data-dcup="${i}" ${i === 0 ? 'disabled' : ''}>↑</button>
|
|
<button class="btn btn-sm" data-dcdown="${i}" ${i === dcDoc.blocks.length - 1 ? 'disabled' : ''}>↓</button>
|
|
<button class="btn btn-sm btn-danger" data-dcdel="${i}">Remove</button>
|
|
</div>`;
|
|
if (b.type === 'text') {
|
|
return `<div class="dc-block">
|
|
<div class="dc-block-head"><span class="dc-kind">Text</span>${tools}</div>
|
|
<textarea data-dctext="${i}" placeholder="Write text here…">${esc(b.text)}</textarea>
|
|
</div>`;
|
|
}
|
|
const prev = b.dataUrl ? `<img class="dc-imgprev" src="${b.dataUrl}" alt="">` : '';
|
|
return `<div class="dc-block">
|
|
<div class="dc-block-head"><span class="dc-kind">Image</span>${tools}</div>
|
|
<div class="field"><input type="file" accept="image/*" data-dcimg="${i}"></div>
|
|
<div class="hint">${b.file ? esc(b.file.name) : 'No image chosen yet'}</div>
|
|
${prev}
|
|
<div class="field" style="margin-top:10px">
|
|
<label>Caption (optional)</label>
|
|
<input type="text" data-dccap="${i}" value="${esc(b.caption || '')}" placeholder="Shown under the image">
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
const empty = dcDoc.blocks.length ? '' : '<div class="dc-empty">No content yet. Add a text or image block to begin.</div>';
|
|
return `<div class="card"><div class="card-body">
|
|
${empty}${blocks}
|
|
<div class="dc-addrow">
|
|
<button class="btn" id="dcAddText">${ICON.plus} Add text</button>
|
|
<button class="btn" id="dcAddImage">${ICON.plus} Add image</button>
|
|
</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
function eventFields(e, path) {
|
|
return `
|
|
<div class="dc-grid">
|
|
<div class="field"><label>Name</label>
|
|
<input type="text" data-tlf="name" data-path="${path}" value="${esc(e.name)}" placeholder="Event name"></div>
|
|
<div class="field"><label>Date</label>
|
|
<input type="date" data-tlf="date" data-path="${path}" value="${esc(e.date)}"></div>
|
|
</div>
|
|
<div class="dc-grid">
|
|
<div class="field"><label>Time</label>
|
|
<input type="time" data-tlf="time" data-path="${path}" value="${esc(e.time)}"></div>
|
|
<div class="field"></div>
|
|
</div>
|
|
<div class="field"><label>Additional Data</label>
|
|
<textarea data-tlf="data" data-path="${path}" placeholder="Notes, context, references…">${esc(e.data)}</textarea>
|
|
</div>`;
|
|
}
|
|
|
|
function timelineBuilderView() {
|
|
const events = dcTL.events.map((ev, i) => {
|
|
const branches = ev.branches.map((b, j) => `
|
|
<div class="dc-ev branch">
|
|
<div class="dc-ev-head"><span class="dc-ev-lbl">Branch of event ${i + 1}</span>
|
|
<button class="btn btn-sm btn-danger" data-tlbdel="${i}.${j}">Remove branch</button></div>
|
|
${eventFields(b, `${i}.${j}`)}
|
|
</div>`).join('');
|
|
return `
|
|
<div class="dc-ev">
|
|
<div class="dc-ev-head"><span class="dc-ev-lbl">Event ${i + 1}</span>
|
|
<div class="dc-tools">
|
|
<button class="btn btn-sm" data-tlup="${i}" ${i === 0 ? 'disabled' : ''}>↑</button>
|
|
<button class="btn btn-sm" data-tldown="${i}" ${i === dcTL.events.length - 1 ? 'disabled' : ''}>↓</button>
|
|
<button class="btn btn-sm btn-danger" data-tldel="${i}">Remove</button>
|
|
</div>
|
|
</div>
|
|
${eventFields(ev, `${i}`)}
|
|
${branches}
|
|
<div class="dc-addrow" style="margin:12px 0 0">
|
|
<button class="btn btn-sm" data-tlbranch="${i}">${ICON.plus} Add branch off this event</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
const empty = dcTL.events.length ? '' : '<div class="dc-empty">No events yet. Add the first main event to begin the timeline.</div>';
|
|
return `<div class="card"><div class="card-body">
|
|
${empty}${events}
|
|
<div class="dc-addrow">
|
|
<button class="btn" id="dcAddEvent">${ICON.plus} Add main event</button>
|
|
</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
function tlByPath(path) {
|
|
const p = String(path).split('.').map(Number);
|
|
let ev = dcTL.events[p[0]];
|
|
if (p.length > 1 && ev) ev = ev.branches[p[1]];
|
|
return ev;
|
|
}
|
|
|
|
function dcShowPreview(html) {
|
|
const m = modal('Preview', '<iframe id="dcPrevFrame" sandbox style="width:100%;height:70vh;border:1px solid var(--line);border-radius:8px;background:#fff"></iframe>', { lg: true });
|
|
const f = m.el.querySelector('#dcPrevFrame');
|
|
if (f) f.srcdoc = html;
|
|
}
|
|
|
|
// Collect document blocks into a spec + a FormData of images.
|
|
function dcBuildDocForm(preview) {
|
|
const fd = new FormData();
|
|
const spec = [];
|
|
let imgIndex = 0;
|
|
for (const b of dcDoc.blocks) {
|
|
if (b.type === 'text') {
|
|
if (!String(b.text || '').trim()) { toast('Every text block needs some text.', 'err'); return null; }
|
|
spec.push({ type: 'text', text: b.text });
|
|
} else {
|
|
if (!b.file) { toast('An image block has no image chosen.', 'err'); return null; }
|
|
fd.append('images', b.file);
|
|
spec.push({ type: 'image', img: imgIndex, caption: b.caption || '' });
|
|
imgIndex += 1;
|
|
}
|
|
}
|
|
if (!spec.length) { toast('Add at least one text or image block.', 'err'); return null; }
|
|
fd.append('spec', JSON.stringify(spec));
|
|
fd.append('title', dcDoc.title || '');
|
|
fd.append('name', dcDoc.name || '');
|
|
fd.append('folder_id', dcDoc.folder_id || '');
|
|
if (preview) fd.append('preview', 'true');
|
|
return fd;
|
|
}
|
|
|
|
async function dcSubmit(preview) {
|
|
try {
|
|
if (dcMode === 'document') {
|
|
if (!preview && !String(dcDoc.name || '').trim()) { toast('A file name is required.', 'err'); return; }
|
|
const fd = dcBuildDocForm(preview);
|
|
if (!fd) return;
|
|
const data = await apiUpload('/api/datacreate/document', fd);
|
|
if (preview) { dcShowPreview(data.html); return; }
|
|
toast('File created', 'ok');
|
|
dcDoc.blocks = []; dcDoc.name = '';
|
|
await loadTree();
|
|
renderDataCreate();
|
|
} else {
|
|
if (!dcTL.events.length) { toast('Add at least one event.', 'err'); return; }
|
|
const nameMissing = (list) => list.some((e) => !String(e.name || '').trim() || (e.branches.length && nameMissing(e.branches)));
|
|
if (nameMissing(dcTL.events)) { toast('Every event and branch needs a Name.', 'err'); return; }
|
|
if (!preview && !String(dcTL.name || '').trim()) { toast('A file name is required.', 'err'); return; }
|
|
const body = {
|
|
folder_id: dcTL.folder_id || '', name: dcTL.name || '', title: dcTL.title || '',
|
|
events: dcTL.events, preview: !!preview,
|
|
};
|
|
const data = await api('/api/datacreate/timeline', { method: 'POST', body });
|
|
if (preview) { dcShowPreview(data.html); return; }
|
|
toast('Timeline file created', 'ok');
|
|
dcTL.events = []; dcTL.name = '';
|
|
await loadTree();
|
|
renderDataCreate();
|
|
}
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
|
|
function bindDataCreate() {
|
|
const byId = (id) => document.getElementById(id);
|
|
const q = (s) => document.querySelectorAll(s);
|
|
|
|
q('[data-dcmode]').forEach((b) => b.onclick = () => { dcMode = b.dataset.dcmode; renderDataCreate(); });
|
|
|
|
const fol = byId('dcFolder'); if (fol) fol.onchange = () => { dcState().folder_id = fol.value; };
|
|
const nm = byId('dcName'); if (nm) nm.oninput = () => { dcState().name = nm.value; };
|
|
const ti = byId('dcTitle'); if (ti) ti.oninput = () => { dcState().title = ti.value; };
|
|
|
|
// Document
|
|
if (byId('dcAddText')) byId('dcAddText').onclick = () => { dcDoc.blocks.push({ type: 'text', text: '' }); renderDataCreate(); };
|
|
if (byId('dcAddImage')) byId('dcAddImage').onclick = () => { dcDoc.blocks.push({ type: 'image', file: null, dataUrl: '', caption: '' }); renderDataCreate(); };
|
|
q('[data-dctext]').forEach((t) => t.oninput = () => { dcDoc.blocks[Number(t.dataset.dctext)].text = t.value; });
|
|
q('[data-dccap]').forEach((c) => c.oninput = () => { dcDoc.blocks[Number(c.dataset.dccap)].caption = c.value; });
|
|
q('[data-dcimg]').forEach((inp) => inp.onchange = () => {
|
|
const i = Number(inp.dataset.dcimg);
|
|
const file = inp.files && inp.files[0];
|
|
if (!file) return;
|
|
if (!/^image\//.test(file.type)) { toast('Please choose an image file.', 'err'); return; }
|
|
if (dcDoc.blocks[i].dataUrl) { try { URL.revokeObjectURL(dcDoc.blocks[i].dataUrl); } catch (_) {} }
|
|
dcDoc.blocks[i].file = file;
|
|
dcDoc.blocks[i].dataUrl = URL.createObjectURL(file);
|
|
renderDataCreate();
|
|
});
|
|
q('[data-dcup]').forEach((b) => b.onclick = () => { const i = Number(b.dataset.dcup); [dcDoc.blocks[i - 1], dcDoc.blocks[i]] = [dcDoc.blocks[i], dcDoc.blocks[i - 1]]; renderDataCreate(); });
|
|
q('[data-dcdown]').forEach((b) => b.onclick = () => { const i = Number(b.dataset.dcdown); [dcDoc.blocks[i + 1], dcDoc.blocks[i]] = [dcDoc.blocks[i], dcDoc.blocks[i + 1]]; renderDataCreate(); });
|
|
q('[data-dcdel]').forEach((b) => b.onclick = () => { dcDoc.blocks.splice(Number(b.dataset.dcdel), 1); renderDataCreate(); });
|
|
|
|
// Timeline
|
|
if (byId('dcAddEvent')) byId('dcAddEvent').onclick = () => { dcTL.events.push(newTLEvent()); renderDataCreate(); };
|
|
q('[data-tlbranch]').forEach((b) => b.onclick = () => { dcTL.events[Number(b.dataset.tlbranch)].branches.push(newTLEvent()); renderDataCreate(); });
|
|
q('[data-tldel]').forEach((b) => b.onclick = () => { dcTL.events.splice(Number(b.dataset.tldel), 1); renderDataCreate(); });
|
|
q('[data-tlbdel]').forEach((b) => b.onclick = () => { const [i, j] = b.dataset.tlbdel.split('.').map(Number); dcTL.events[i].branches.splice(j, 1); renderDataCreate(); });
|
|
q('[data-tlup]').forEach((b) => b.onclick = () => { const i = Number(b.dataset.tlup); [dcTL.events[i - 1], dcTL.events[i]] = [dcTL.events[i], dcTL.events[i - 1]]; renderDataCreate(); });
|
|
q('[data-tldown]').forEach((b) => b.onclick = () => { const i = Number(b.dataset.tldown); [dcTL.events[i + 1], dcTL.events[i]] = [dcTL.events[i], dcTL.events[i + 1]]; renderDataCreate(); });
|
|
q('[data-tlf]').forEach((inp) => {
|
|
const handler = () => { const ev = tlByPath(inp.dataset.path); if (ev) ev[inp.dataset.tlf] = inp.value; };
|
|
inp.oninput = handler; inp.onchange = handler;
|
|
});
|
|
|
|
// Actions
|
|
if (byId('dcPreview')) byId('dcPreview').onclick = () => dcSubmit(true);
|
|
if (byId('dcSave')) byId('dcSave').onclick = () => dcSubmit(false);
|
|
}
|
|
|
|
function renderTab() {
|
|
const v = document.getElementById('tabView');
|
|
if (tab === 'files') { v.innerHTML = filesView(); bindFiles(); }
|
|
else if (tab === 'datacreate') { v.innerHTML = dataCreateView(); bindDataCreate(); }
|
|
else if (tab === 'requests') { v.innerHTML = requestsView(); bindRequests(); }
|
|
else if (tab === 'templates') { v.innerHTML = templatesView(); bindTemplates(); }
|
|
else if (tab === 'images') { v.innerHTML = imagesView(); bindImages(); }
|
|
else if (tab === 'users') { v.innerHTML = usersView(); bindUsers(); }
|
|
else if (tab === 'versions') { v.innerHTML = versionsView(); bindVersions(); }
|
|
else if (tab === 'legislation') { v.innerHTML = legislationView(); bindLegislation(); }
|
|
else if (tab === 'mailcfg') { v.innerHTML = mailCfgView(); bindMailCfg(); }
|
|
else if (tab === 'maillog') { v.innerHTML = mailLogView(); bindMailLog(); }
|
|
}
|
|
|
|
async function loadTree() { tree = await api('/api/manage/tree'); if (tab === 'files') renderTab(); }
|
|
async function loadRequests() { requests = (await api('/api/data/requests')).requests; }
|
|
async function loadTemplates() { templates = (await api('/settings/templates')).templates; if (tab === 'templates') renderTab(); }
|
|
async function loadImages() { images = (await api('/settings/images')).images; }
|
|
async function loadUsers() { users = (await api('/api/users')).users; }
|
|
async function loadVersions() { versions = (await api('/api/versions')).entries; }
|
|
async function loadLegislation() { legislation = (await api('/api/legislation')).entries; }
|
|
async function loadMailCfg() { mailCfg = (await api('/api/mail/config')).config; }
|
|
async function loadMailLog() {
|
|
const p = new URLSearchParams();
|
|
if (mailFilter.q) p.set('q', mailFilter.q);
|
|
if (mailFilter.direction) p.set('direction', mailFilter.direction);
|
|
if (mailFilter.status) p.set('status', mailFilter.status);
|
|
mailLog = await api('/api/mail/log?' + p.toString());
|
|
}
|
|
|
|
await Promise.all([loadTree(), loadRequests(), loadTemplates(), loadImages(), loadUsers(),
|
|
loadVersions(), loadLegislation(), loadMailCfg(), loadMailLog()]);
|
|
renderTabs();
|
|
renderTab();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|