Files
2026-09-13 20:12:28 +01:00

1335 lines
61 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 · Martinhal ISDSS</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; }
</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';
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 &amp; Folders' },
{ 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 recorded date</span>';
return `<span class="fdate">rec ${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="Recorded 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 recorded 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>Recorded 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 this material was recorded;
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 = 'A recorded 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(`Recorded date — ${name}`, `
<div class="field"><label>Recorded 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 recorded 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 = 'A recorded 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('Recorded 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='&lt;img src="${location.origin}${i.url}" alt="${esc(i.label)}" style="max-width:200px"&gt;'>🖼 ${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="ISDSS &lt;no-reply@yourdomain.com&gt;"></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://isdss.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 ----------------
function renderTab() {
const v = document.getElementById('tabView');
if (tab === 'files') { v.innerHTML = filesView(); bindFiles(); }
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>