v_2.3
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>View Data · Infosec</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const shell = await buildShell('view'); if (!shell) return;
|
||||
const { isAdmin, content } = shell;
|
||||
let state = { folders: [], files: [], requestState: {} };
|
||||
|
||||
async function load() {
|
||||
state = await api('/api/data/tree');
|
||||
setClockOffset(state.server_time);
|
||||
render();
|
||||
startTimers();
|
||||
}
|
||||
|
||||
function children(parentId) {
|
||||
return {
|
||||
folders: state.folders.filter((f) => (f.parent_id || null) === parentId),
|
||||
files: state.files.filter((f) => (f.folder_id || null) === parentId),
|
||||
};
|
||||
}
|
||||
|
||||
function reqBadge(type, id) {
|
||||
const s = state.requestState[`${type}:${id}`];
|
||||
if (s === 'approved') return `<span class="badge badge-approved">Approved</span>`;
|
||||
if (s === 'pending') return `<span class="badge badge-pending">Pending</span>`;
|
||||
if (s === 'denied') return `<span class="badge badge-denied">Declined</span>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
// Files inside a folder the user can open need no request of their own —
|
||||
// access to the folder implies access to its contents.
|
||||
function fileActions(f) {
|
||||
if (f.accessible) {
|
||||
return `<a class="btn btn-sm" href="/api/data/view/file/${f.id}" target="_blank" rel="noopener">${ICON.view} Open</a>
|
||||
<a class="btn btn-sm btn-primary" href="/api/data/download/file/${f.id}">${ICON.download} Download</a>`;
|
||||
}
|
||||
const s = state.requestState[`file:${f.id}`];
|
||||
if (s === 'pending') return `<button class="btn btn-sm" disabled>Requested</button>`;
|
||||
return `<button class="btn btn-sm" data-req="file" data-id="${f.id}">${ICON.mail} Request access</button>`;
|
||||
}
|
||||
|
||||
function folderActions(f) {
|
||||
if (f.accessible) return isAdmin ? '' : `<span class="badge badge-approved">Access granted</span>`;
|
||||
const s = state.requestState[`folder:${f.id}`];
|
||||
if (s === 'pending') return `<button class="btn btn-sm" disabled>Requested</button>`;
|
||||
return `<button class="btn btn-sm" data-req="folder" data-id="${f.id}">${ICON.mail} Request access</button>`;
|
||||
}
|
||||
|
||||
// Both dates are shown for every folder, whether or not access is granted.
|
||||
// "Legal Validity" is the recorded date plus 30 days, calculated server-side.
|
||||
function folderDates(fo) {
|
||||
const rec = fo.recorded_date
|
||||
? `<span class="date-value">${esc(fmtDate(fo.recorded_date))}</span>`
|
||||
: '<span class="date-value muted-value">not set</span>';
|
||||
let val;
|
||||
if (!fo.legal_validity) {
|
||||
val = '<span class="date-value muted-value">not set</span>';
|
||||
} else {
|
||||
const expired = fo.legal_validity < today();
|
||||
val = `<span class="date-value${expired ? ' date-expired' : ''}">${esc(fmtDate(fo.legal_validity))}${
|
||||
expired ? ' (expired)' : ''}</span>`;
|
||||
}
|
||||
return `<div class="folder-dates">
|
||||
<span class="date-item"><span class="date-label">Incident Date</span>${rec}</span>
|
||||
<span class="date-item"><span class="date-label">Legal Validity</span>${val}</span>
|
||||
<span class="date-item"><span class="date-label">Access</span>${accessCell(fo)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10); }
|
||||
|
||||
// ---- Access countdown -------------------------------------------------
|
||||
// The span carries the expiry as a data attribute; a single ticking timer
|
||||
// updates every one of them once a second.
|
||||
function accessCell(fo) {
|
||||
const a = fo.access || { state: 'denied' };
|
||||
if (a.state === 'admin') {
|
||||
return '<span class="access-timer access-perm">Full access (administrator)</span>';
|
||||
}
|
||||
if (a.state === 'permanent') {
|
||||
return '<span class="access-timer access-perm">Access granted — no expiry</span>';
|
||||
}
|
||||
if (a.state === 'timed') {
|
||||
return `<span class="access-timer access-live" data-expires="${esc(a.expires_at)}">…</span>`;
|
||||
}
|
||||
return '<span class="access-timer access-denied">Access Denied</span>';
|
||||
}
|
||||
|
||||
// Difference between server time and this browser's clock, so the countdown
|
||||
// stays honest even if the local clock is wrong.
|
||||
let clockOffset = 0;
|
||||
function setClockOffset(serverTime) {
|
||||
if (!serverTime) return;
|
||||
const server = Date.parse(String(serverTime).replace(' ', 'T') + 'Z');
|
||||
if (!Number.isNaN(server)) clockOffset = server - Date.now();
|
||||
}
|
||||
|
||||
function remainingText(expiresAt) {
|
||||
const end = Date.parse(String(expiresAt).replace(' ', 'T') + 'Z');
|
||||
if (Number.isNaN(end)) return '—';
|
||||
let ms = end - (Date.now() + clockOffset);
|
||||
if (ms <= 0) return 'expired';
|
||||
const s = Math.floor(ms / 1000);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
if (d > 0) return `${d}d ${pad(h)}h ${pad(m)}m ${pad(sec)}s`;
|
||||
return `${pad(h)}h ${pad(m)}m ${pad(sec)}s`;
|
||||
}
|
||||
|
||||
let tickHandle = null;
|
||||
function tickTimers() {
|
||||
const nodes = document.querySelectorAll('.access-live[data-expires]');
|
||||
let anyExpired = false;
|
||||
nodes.forEach((el) => {
|
||||
const txt = remainingText(el.dataset.expires);
|
||||
el.textContent = txt === 'expired' ? 'Access Denied' : txt;
|
||||
if (txt === 'expired') {
|
||||
el.classList.remove('access-live');
|
||||
el.classList.add('access-denied');
|
||||
anyExpired = true;
|
||||
} else {
|
||||
// warn when under an hour remains
|
||||
el.classList.toggle('access-soon', (Date.parse(String(el.dataset.expires).replace(' ', 'T') + 'Z')
|
||||
- (Date.now() + clockOffset)) < 3600000);
|
||||
}
|
||||
});
|
||||
// Once something lapses, refresh so the folder's contents disappear too.
|
||||
if (anyExpired) load();
|
||||
}
|
||||
|
||||
function startTimers() {
|
||||
if (tickHandle) clearInterval(tickHandle);
|
||||
tickTimers();
|
||||
tickHandle = setInterval(tickTimers, 1000);
|
||||
}
|
||||
|
||||
function renderNode(parentId) {
|
||||
const { folders, files } = children(parentId);
|
||||
if (!folders.length && !files.length) return '';
|
||||
let html = '<ul class="tree">';
|
||||
for (const fo of folders) {
|
||||
// A locked folder shows its name only: no contents, no counts.
|
||||
const locked = !fo.accessible;
|
||||
html += `<li>
|
||||
<div class="row${locked ? ' locked' : ''}">
|
||||
<span class="ic folder">${locked ? ICON.lock : ICON.folder}</span>
|
||||
<span class="name">${esc(fo.name)}</span> ${reqBadge('folder', fo.id)}
|
||||
${locked ? '<span class="locked-note">Access required</span>' : ''}
|
||||
<span class="spacer"></span>
|
||||
<span class="actions" style="opacity:1">${folderActions(fo)}</span>
|
||||
</div>
|
||||
${folderDates(fo)}
|
||||
${locked ? '' : renderNode(fo.id)}
|
||||
</li>`;
|
||||
}
|
||||
for (const fi of files) {
|
||||
html += `<li>
|
||||
<div class="row">
|
||||
<span class="ic">${ICON.file}</span>
|
||||
<span class="name">${esc(fi.name)}</span>
|
||||
<span class="meta">${fmtBytes(fi.size)}</span> ${fi.accessible ? '' : reqBadge('file', fi.id)}
|
||||
<span class="spacer"></span>
|
||||
<span class="actions" style="opacity:1">${fileActions(fi)}</span>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
return html + '</ul>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tree = renderNode(null);
|
||||
content.innerHTML = `
|
||||
<div class="page-head">
|
||||
<h1>View Data</h1>
|
||||
<p>${isAdmin
|
||||
? 'Everything published in Data Management, as your users will see it.'
|
||||
: 'Request access to a folder to see what it contains. Once approved, everything inside is available to you.'}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Available data</h2></div>
|
||||
<div class="card-body">
|
||||
${tree || `<div class="empty">${ICON.folder}<div>No data has been published yet.</div></div>`}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
content.querySelectorAll('[data-req]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api('/api/data/request', { method: 'POST', body: { target_type: btn.dataset.req, target_id: Number(btn.dataset.id) } });
|
||||
toast('Request sent to administrators', 'ok');
|
||||
await load();
|
||||
} catch (e) { toast(e.message, 'err'); btn.disabled = false; }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user