This commit is contained in:
jpmvaz
2026-09-13 20:10:17 +01:00
parent 9b5cc30fb4
commit b192fb3f17
482 changed files with 0 additions and 48205 deletions
+235
View File
@@ -0,0 +1,235 @@
'use strict';
const FOOTER_TEXT = '© 2026 Martinhal IT - Joao Vaz - Version 2.3';
// Dates are stored as ISO (YYYY-MM-DD) so they sort correctly, but are always
// shown to people as DD-MM-YYYY.
function fmtDate(value) {
if (!value) return '';
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : String(value);
}
function fmtDateTime(value) {
if (!value) return '';
const str = String(value).replace('T', ' ').replace('Z', '');
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}:\d{2})(:\d{2})?)?/);
if (!m) return str;
const date = `${m[3]}-${m[2]}-${m[1]}`;
return m[4] ? `${date} ${m[4]}${m[5] || ''}` : date;
}
// Accepts DD-MM-YYYY (what people type) or YYYY-MM-DD (native date inputs)
// and returns ISO for the API, or '' when the value is not a real date.
function toIsoDate(value) {
const v = String(value || '').trim();
if (!v) return '';
let iso = '';
const dmy = v.match(/^(\d{2})-(\d{2})-(\d{4})$/);
if (dmy) iso = `${dmy[3]}-${dmy[2]}-${dmy[1]}`;
else if (/^\d{4}-\d{2}-\d{2}$/.test(v)) iso = v;
else return '';
const [y, mo, da] = iso.split('-').map(Number);
const dt = new Date(Date.UTC(y, mo - 1, da));
return (dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === da) ? iso : '';
}
const ACCESS_DURATIONS = [
{ key: '24h', label: 'Valid for 24 hours' },
{ key: '15d', label: 'Valid for 15 days' },
{ key: '30d', label: 'Valid for 30 days' },
{ key: 'forever', label: 'Valid forever' },
];
const ICON = {
storage: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/></svg>',
caret: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>',
calendar: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>',
legislation: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v16M5 8h14M7 8l-3 6a3 3 0 0 0 6 0L7 8zM17 8l-3 6a3 3 0 0 0 6 0l-3-6zM9 20h6"/></svg>',
version: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.5 6H13a2.5 2.5 0 0 1 2.5 2.5v1M8.5 18H13a2.5 2.5 0 0 0 2.5-2.5v-1"/></svg>',
lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>',
view: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
manage: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h5l2 2h11v9a2 2 0 0 1-2 2H3Z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2"/></svg>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4Z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg>',
account: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h6l2 2h10v9a2 2 0 0 1-2 2H3Z"/></svg>',
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h8l4 4v16H6Z"/><path d="M14 2v4h4"/></svg>',
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l7 3v6c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6Z"/></svg>',
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>',
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 21h16"/></svg>',
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h4L18 10l-4-4L4 16Z"/><path d="m14 6 4 4"/></svg>',
move: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 9 2 12l3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/></svg>',
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15V3m0 0 4 4m-4-4L8 7"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>',
};
async function api(url, opts = {}) {
const o = Object.assign({ headers: {} }, opts);
if (o.body && !(o.body instanceof FormData)) {
o.headers['Content-Type'] = 'application/json';
o.body = JSON.stringify(o.body);
}
const res = await fetch(url, o);
if (res.status === 401) { location.href = '/login.html'; throw new Error('Not authenticated'); }
const ct = res.headers.get('content-type') || '';
const data = ct.includes('application/json') ? await res.json() : await res.text();
if (!res.ok) throw new Error((data && data.error) || 'Request failed');
return data;
}
// Upload with progress. fetch() cannot report upload progress, so this uses
// XMLHttpRequest and calls onProgress(percentOrNull) as bytes go out.
function apiUpload(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.upload.addEventListener('progress', (e) => {
if (onProgress) onProgress(e.lengthComputable ? Math.round((e.loaded / e.total) * 100) : null);
});
xhr.addEventListener('load', () => {
if (xhr.status === 401) { location.href = '/login.html'; return reject(new Error('Not authenticated')); }
let data = null;
try { data = JSON.parse(xhr.responseText); } catch (_) { /* non-JSON */ }
if (xhr.status >= 200 && xhr.status < 300) return resolve(data || {});
reject(new Error((data && data.error) || `Upload failed (${xhr.status})`));
});
xhr.addEventListener('error', () => reject(new Error('Network error during upload.')));
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled.')));
xhr.send(formData);
});
}
function toast(msg, kind = '') {
let box = document.querySelector('.toasts');
if (!box) { box = document.createElement('div'); box.className = 'toasts'; document.body.appendChild(box); }
const t = document.createElement('div');
t.className = 'toast ' + kind;
t.textContent = msg;
box.appendChild(t);
setTimeout(() => t.remove(), 3600);
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function fmtBytes(n) {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; n = Number(n);
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
function modal(title, bodyHtml, opts = {}) {
const back = document.createElement('div');
back.className = 'modal-back';
back.innerHTML = `<div class="modal ${opts.lg ? 'lg' : ''}">
<div class="modal-head">${esc(title)}</div>
<div class="modal-body">${bodyHtml}</div>
<div class="modal-foot"></div></div>`;
const foot = back.querySelector('.modal-foot');
(opts.buttons || [{ label: 'Close' }]).forEach((b) => {
const btn = document.createElement('button');
btn.className = 'btn ' + (b.className || '');
btn.textContent = b.label;
btn.onclick = () => { if (!b.onClick || b.onClick(back) !== false) close(); };
foot.appendChild(btn);
});
function close() { back.remove(); }
back.addEventListener('mousedown', (e) => { if (e.target === back && !opts.sticky) close(); });
document.body.appendChild(back);
return { el: back, close };
}
function prompt2(title, fields, onSubmit, submitLabel = 'Save') {
const body = fields.map((f) => `
<div class="field">
<label>${esc(f.label)}</label>
${f.type === 'select'
? `<select data-k="${f.key}">${f.options.map((o) => `<option value="${esc(o.value)}" ${o.value == f.value ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>`
: `<input type="text" data-k="${f.key}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}">`}
</div>`).join('');
const m = modal(title, body, {
buttons: [
{ label: 'Cancel' },
{ label: submitLabel, className: 'btn-primary', onClick: (back) => {
const vals = {};
back.querySelectorAll('[data-k]').forEach((el) => vals[el.dataset.k] = el.value.trim());
Promise.resolve(onSubmit(vals)).then((r) => { if (r === false) {} });
return false; // keep open; caller closes
} },
],
});
return m;
}
async function buildShell(active) {
let me;
try { me = (await api('/api/auth/me')).user; }
catch { location.href = '/login.html'; return null; }
const isAdmin = me.role === 'admin';
const link = (href, icon, label, key) =>
`<a href="${href}" class="${active === key ? 'active' : ''}">${ICON[icon]}<span>${label}</span></a>`;
const shell = document.createElement('div');
shell.className = 'app';
shell.innerHTML = `
<aside class="sidebar">
<div class="brand">
<a class="brand-logo" href="/view-data.html" aria-label="Infosec home">
<img src="/assets/infosec-logo.png" alt="Infosec">
</a>
<div class="mark fallback-mark" hidden><span class="logo">I</span> Infosec</div>
<div class="tag">Martinhal IT · secure portal</div>
</div>
<nav class="nav">
${link('/view-data.html', 'view', 'View Data', 'view')}
${link('/calendar-view.html', 'calendar', 'Calendar View', 'calendar')}
${link('/legislation.html', 'legislation', 'Legislation', 'legislation')}
${link('/account.html', 'account', 'My Account', 'account')}
${isAdmin ? `<div class="section-label">Administration</div>
${link('/data-management.html', 'manage', 'Data Management', 'manage')}
${link('/storage.html', 'storage', 'Storage', 'storage')}
${link('/logs.html', 'logs', 'Logs', 'logs')}
${link('/version-control.html', 'version', 'Version Control', 'version')}` : ''}
</nav>
<div class="side-user">
<div class="u-identity">
<span class="u-avatar">${me.avatar
? `<img src="${esc(me.avatar)}" alt="">`
: `<span class="avatar-initials">${esc((me.username || '?').slice(0, 2).toUpperCase())}</span>`}</span>
<span class="u-text">
<span class="u-name">${esc(me.username)}</span>
<span class="u-role">${esc(me.role)}${me.mfa_enabled ? ' · MFA on' : ''}</span>
</span>
</div>
<button id="logoutBtn">Sign out</button>
</div>
</aside>
<div class="main">
<div class="content" id="content"></div>
<div class="footer">${FOOTER_TEXT}</div>
</div>`;
document.body.appendChild(shell);
// If the logo file is ever missing, fall back to the original wordmark
// rather than showing a broken image.
const logoImg = shell.querySelector('.brand-logo img');
if (logoImg) {
logoImg.addEventListener('error', () => {
const link = shell.querySelector('.brand-logo');
const fallback = shell.querySelector('.fallback-mark');
if (link) link.hidden = true;
if (fallback) fallback.hidden = false;
});
}
shell.querySelector('#logoutBtn').onclick = async () => {
await api('/api/auth/logout', { method: 'POST' });
location.href = '/login.html';
};
return { me, isAdmin, content: shell.querySelector('#content') };
}