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
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Account · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('account'); if (!shell) return;
const { me, content } = shell;
function render() {
content.innerHTML = `
<div class="page-head">
<h1>My Account</h1>
<p>Manage your sign-in security.</p>
</div>
<div class="card" style="max-width:640px">
<div class="card-head"><h2>Profile</h2></div>
<div class="card-body">
<div class="profile-row">
<div class="avatar-lg" id="avatarPreview">${avatarMarkup(me)}</div>
<div style="flex:1">
<p><strong>Username:</strong> ${esc(me.username)}</p>
<p><strong>Email:</strong> ${esc(me.email)}</p>
<p><strong>Role:</strong> <span class="badge badge-admin">${esc(me.role)}</span></p>
</div>
</div>
<div class="divider"></div>
<h3 style="margin:0 0 6px">Profile picture</h3>
<p class="hint" style="margin:0 0 12px">
Shown next to your name in the sidebar. PNG, JPEG, WebP or GIF, up to 4 MB.
</p>
<div class="row-actions" style="justify-content:flex-start">
<label class="btn btn-primary">
${me.avatar ? 'Change picture' : 'Upload picture'}
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
</label>
<button class="btn btn-danger" id="avatarRemove" ${me.avatar ? '' : 'disabled'}>Remove</button>
</div>
<div class="err-line" id="avatarErr"></div>
</div>
</div>
<div class="card" style="max-width:640px">
<div class="card-head">
<h2>Multi-factor authentication</h2>
<span class="badge ${me.mfa_enabled ? 'badge-approved' : 'badge-pending'}">${me.mfa_enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div class="card-body" id="mfaBody"></div>
</div>`;
renderMfa();
}
function renderMfa() {
const body = document.getElementById('mfaBody');
if (me.mfa_enabled) {
body.innerHTML = `
<p>Your account is protected with an authenticator app. You'll be asked for a code each time you sign in.</p>
<div class="field" style="max-width:320px">
<label>Confirm password to turn off MFA</label>
<input type="password" id="offpass">
</div>
<button class="btn btn-danger" id="disableBtn">Turn off MFA</button>`;
document.getElementById('disableBtn').onclick = async () => {
try {
await api('/api/auth/mfa/disable', { method: 'POST', body: { password: document.getElementById('offpass').value } });
me.mfa_enabled = false; toast('MFA disabled', 'ok'); render();
} catch (e) { toast(e.message, 'err'); }
};
} else {
body.innerHTML = `
<p>Add a second layer of security. You'll scan a QR code with an authenticator app (Google Authenticator, Authy, 1Password…) and enter a code to confirm.</p>
<button class="btn btn-primary" id="startBtn">${ICON.shield} Set up MFA</button>`;
document.getElementById('startBtn').onclick = startSetup;
}
}
async function startSetup() {
const r = await api('/api/auth/mfa/setup', { method: 'POST' });
modal('Set up multi-factor authentication', `
<div class="qr-box">
<img src="${r.qr}" alt="QR code">
<p class="hint">Scan with your authenticator app, or enter this key manually:</p>
<div class="secret">${esc(r.base32)}</div>
</div>
<div class="field" style="margin-top:20px">
<label>Enter the 6-digit code to confirm</label>
<input type="text" id="confirmCode" class="otp-input" inputmode="numeric" maxlength="6" placeholder="••••••">
</div>
<div class="err-line" id="mfaErr"></div>`, {
sticky: true,
buttons: [
{ label: 'Cancel' },
{ label: 'Enable MFA', className: 'btn-primary', onClick: async (back) => {
try {
await api('/api/auth/mfa/enable', { method: 'POST', body: { token: back.querySelector('#confirmCode').value.trim() } });
me.mfa_enabled = true; toast('MFA enabled', 'ok'); back.remove(); render();
} catch (e) { back.querySelector('#mfaErr').textContent = e.message; }
return false;
} },
],
});
}
// Picture, or the user's initials on a coloured disc as a fallback.
function avatarMarkup(u) {
if (u.avatar) return `<img src="${esc(u.avatar)}" alt="Your profile picture">`;
const initials = (u.username || '?').slice(0, 2).toUpperCase();
return `<span class="avatar-initials">${esc(initials)}</span>`;
}
function bindAvatar() {
const fileInput = document.getElementById('avatarFile');
const removeBtn = document.getElementById('avatarRemove');
const err = document.getElementById('avatarErr');
if (fileInput) fileInput.onchange = async () => {
err.textContent = '';
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (file.size > 4 * 1024 * 1024) { err.textContent = 'That image is larger than 4 MB.'; return; }
const fd = new FormData();
fd.append('avatar', file);
try {
const r = await api('/api/auth/avatar', { method: 'POST', body: fd });
me.avatar = r.avatar;
toast('Profile picture updated', 'ok');
render(); bindAvatar();
refreshSidebarAvatar();
} catch (e) { err.textContent = e.message; }
};
if (removeBtn) removeBtn.onclick = async () => {
err.textContent = '';
try {
await api('/api/auth/avatar', { method: 'DELETE' });
me.avatar = null;
toast('Profile picture removed', 'ok');
render(); bindAvatar();
refreshSidebarAvatar();
} catch (e) { err.textContent = e.message; }
};
}
// Update the sidebar avatar live, without a page reload.
function refreshSidebarAvatar() {
const holder = document.querySelector('.side-user .u-avatar');
if (!holder) return;
holder.innerHTML = me.avatar
? `<img src="${esc(me.avatar)}?t=${Date.now()}" alt="">`
: `<span class="avatar-initials">${esc((me.username || '?').slice(0, 2).toUpperCase())}</span>`;
}
const _origRender = render;
render = function () { _origRender(); bindAvatar(); };
render();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+191
View File
@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calendar View · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.cal-head { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.cal-head .cal-title { font-weight:800; font-size:18px; letter-spacing:-.01em; margin:0; }
.cal-head .spacer { flex:1; }
.cal-nav { display:flex; align-items:center; gap:6px; }
.cal-legend { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:13px; }
.cal-legend .dot { width:10px; height:10px; border-radius:50%; background:var(--accent); display:inline-block; }
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:6px; margin-top:14px; }
.cal-dow { text-align:center; font-size:11px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
color:var(--muted); padding:4px 0; }
.cal-cell { min-height:82px; border:1px solid var(--line); border-radius:10px; padding:8px; background:var(--surface);
display:flex; flex-direction:column; gap:6px; text-align:left; cursor:default; position:relative; }
.cal-cell.blank { border:none; background:transparent; }
.cal-cell .daynum { font-size:13px; font-weight:600; color:var(--text); }
.cal-cell.today .daynum { background:var(--accent); color:#fff; border-radius:50%; width:24px; height:24px;
display:grid; place-items:center; }
.cal-cell.has-events { cursor:pointer; border-color:var(--accent); background:var(--accent-soft); }
.cal-cell.has-events:hover { box-shadow:var(--shadow); }
.cal-cell.selected { outline:2px solid var(--accent); outline-offset:0; }
.cal-count { margin-top:auto; align-self:flex-start; background:var(--accent); color:#fff; border-radius:999px;
font-size:11px; font-weight:700; padding:2px 8px; }
.cal-count .lbl { font-weight:600; opacity:.9; }
.day-panel { margin-top:18px; }
.day-panel h3 { margin:0 0 10px; font-size:15px; }
.ev-row { display:flex; align-items:center; gap:12px; flex-wrap:wrap; border:1px solid var(--line);
border-radius:10px; padding:12px 14px; margin-bottom:10px; background:var(--surface); }
.ev-row .ev-name { font-weight:700; }
.ev-row .ev-dates { color:var(--muted); font-size:13px; }
.ev-row .ev-dates .expired { color:var(--danger); font-weight:600; }
.ev-row .spacer { flex:1; }
.cal-empty { color:var(--muted); font-size:14px; padding:8px 2px; }
@media (max-width:640px){ .cal-cell { min-height:64px; padding:6px; } .cal-cell .daynum { font-size:12px; } }
</style>
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('calendar'); if (!shell) return;
const { content } = shell;
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const DOW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
let events = []; // { id, name, date(ISO), legal_validity, accessible, access }
let byDate = {}; // iso -> [events]
let view = {}; // { y, m } (m is 0-11)
let selected = null; // iso string or null
let todayIso = null;
const pad = (n) => String(n).padStart(2, '0');
const iso = (y, m, d) => `${y}-${pad(m + 1)}-${pad(d)}`;
const daysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
// Monday-based index of the 1st of the month (0 = Monday … 6 = Sunday)
const firstIndex = (y, m) => (new Date(y, m, 1).getDay() + 6) % 7;
function accessBadge(ev) {
const a = ev.access || { state: ev.accessible ? 'permanent' : 'denied' };
if (a.state === 'admin') return '<span class="badge badge-approved">Full access</span>';
if (a.state === 'permanent') return '<span class="badge badge-approved">Access granted</span>';
if (a.state === 'timed') return '<span class="badge badge-approved">Access granted (timed)</span>';
return '<span class="badge badge-denied">Access required</span>';
}
async function load() {
const state = await api('/api/data/tree');
// "Events" are folders that carry an Incident Date — each folder is a dated
// item (an incident/recording) shown on its incident day.
events = (state.folders || [])
.filter((f) => f.recorded_date)
.map((f) => ({
id: f.id, name: f.name, date: f.recorded_date, legal_validity: f.legal_validity,
accessible: f.accessible, access: f.access,
}));
byDate = {};
for (const ev of events) (byDate[ev.date] = byDate[ev.date] || []).push(ev);
const t = state.server_time ? new Date(String(state.server_time).replace(' ', 'T') + 'Z') : new Date();
todayIso = iso(t.getFullYear(), t.getMonth(), t.getDate());
view = { y: t.getFullYear(), m: t.getMonth() };
selected = byDate[todayIso] ? todayIso : null;
render();
}
function calGrid() {
const { y, m } = view;
const cells = [];
const lead = firstIndex(y, m);
for (let i = 0; i < lead; i++) cells.push('<div class="cal-cell blank"></div>');
const total = daysInMonth(y, m);
for (let d = 1; d <= total; d++) {
const id = iso(y, m, d);
const list = byDate[id] || [];
const cls = ['cal-cell'];
if (list.length) cls.push('has-events');
if (id === todayIso) cls.push('today');
if (id === selected) cls.push('selected');
const count = list.length
? `<span class="cal-count">${list.length} <span class="lbl">event${list.length > 1 ? 's' : ''}</span></span>`
: '';
const clickable = list.length ? ` data-iso="${id}"` : '';
cells.push(`<div class="${cls.join(' ')}"${clickable}><span class="daynum">${d}</span>${count}</div>`);
}
return `<div class="cal-grid">
${DOW.map((w) => `<div class="cal-dow">${w}</div>`).join('')}
${cells.join('')}
</div>`;
}
function dayPanel() {
if (!selected) {
return '<div class="day-panel"><div class="cal-empty">Select a highlighted day to see the events recorded on it.</div></div>';
}
const list = (byDate[selected] || []).slice().sort((a, b) => a.name.localeCompare(b.name));
const rows = list.map((ev) => {
const expired = ev.legal_validity && ev.legal_validity < todayIso;
const valid = ev.legal_validity
? `<span class="${expired ? 'expired' : ''}">valid to ${esc(fmtDate(ev.legal_validity))}${expired ? ' (expired)' : ''}</span>`
: 'no validity';
const open = ev.accessible
? '<a class="btn btn-sm" href="/view-data.html">Open in View Data</a>'
: '';
return `<div class="ev-row">
<span class="ic">${ICON.folder}</span>
<span class="ev-name">${esc(ev.name)}</span>
<span class="ev-dates">incident ${esc(fmtDate(ev.date))} · ${valid}</span>
<span class="spacer"></span>
${accessBadge(ev)}
${open}
</div>`;
}).join('');
return `<div class="day-panel">
<h3>${esc(fmtDate(selected))}${list.length} event${list.length > 1 ? 's' : ''}</h3>
${rows}
</div>`;
}
function render() {
const monthHasNone = !events.length;
content.innerHTML = `
<div class="page-head">
<h1>Calendar View</h1>
<p>A month-by-month overview of the days that have events. Each event is a folder's Incident Date; highlighted days can be opened to list them.</p>
</div>
<div class="card">
<div class="card-body">
<div class="cal-head">
<div class="cal-nav">
<button class="btn btn-sm" id="calPrev"> Prev</button>
<button class="btn btn-sm" id="calToday">Today</button>
<button class="btn btn-sm" id="calNext">Next </button>
</div>
<h2 class="cal-title">${MONTHS[view.m]} ${view.y}</h2>
<span class="spacer"></span>
<span class="cal-legend"><span class="dot"></span> day with events</span>
</div>
${calGrid()}
${monthHasNone ? '<div class="cal-empty" style="margin-top:14px">No events yet. Folders you can see with an Incident Date will appear here.</div>' : dayPanel()}
</div>
</div>`;
const prev = document.getElementById('calPrev');
const next = document.getElementById('calNext');
const today = document.getElementById('calToday');
if (prev) prev.onclick = () => { view.m -= 1; if (view.m < 0) { view.m = 11; view.y -= 1; } render(); };
if (next) next.onclick = () => { view.m += 1; if (view.m > 11) { view.m = 0; view.y += 1; } render(); };
if (today) today.onclick = () => {
const [ty, tm] = todayIso.split('-').map(Number);
view = { y: ty, m: tm - 1 };
selected = byDate[todayIso] ? todayIso : selected;
render();
};
content.querySelectorAll('[data-iso]').forEach((cell) => {
cell.onclick = () => { selected = cell.dataset.iso; render(); };
});
}
load();
})();
</script>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
:root {
--ink: #131820;
--ink-2: #1c2530;
--canvas: #eef1f5;
--surface: #ffffff;
--line: #dde3ea;
--text: #1b2330;
--muted: #67707e;
--accent: #1f7a70; /* deep teal - "vault" */
--accent-ink: #0f463f;
--accent-soft: #e5f2f0;
--clay: #b5642a;
--danger: #c23b46;
--danger-soft: #fbe9ea;
--warn: #b8860b;
--ok: #2e7d52;
--radius: 10px;
--shadow: 0 1px 2px rgba(16,24,40,.06), 0 8px 24px rgba(16,24,40,.06);
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: var(--sans);
color: var(--text);
background: var(--canvas);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- App shell ---------- */
.app { display: grid; grid-template-columns: 248px 1fr; min-height: 100vh; }
.sidebar {
background: var(--ink);
color: #cdd6e2;
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
}
.brand {
padding: 22px 20px 18px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.brand .mark {
display: flex; align-items: center; gap: 10px;
font-weight: 700; letter-spacing: -.01em; color: #fff; font-size: 17px;
}
/* The wordmark is only a fallback for a missing logo image; keep it hidden
(the `hidden` attribute is otherwise overridden by the display:flex above). */
.brand .mark[hidden] { display: none; }
.brand .logo {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #2aa596);
display: grid; place-items: center; color: #fff; font-weight: 800;
}
.brand .tag { color: #8592a4; font-size: 12px; margin-top: 4px; letter-spacing: .02em; }
.nav { padding: 12px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav a {
display: flex; align-items: center; gap: 11px;
padding: 10px 12px; border-radius: 8px; color: #b7c1d0;
font-weight: 500; font-size: 14.5px;
}
.nav a:hover { background: rgba(255,255,255,.06); color: #fff; text-decoration: none; }
.nav a.active { background: var(--accent); color: #fff; }
.nav a svg { width: 18px; height: 18px; flex: none; opacity: .95; }
.nav .section-label {
font-size: 11px; text-transform: uppercase; letter-spacing: .09em;
color: #6b7788; padding: 14px 12px 6px;
}
.side-user {
border-top: 1px solid rgba(255,255,255,.08);
padding: 14px 16px; font-size: 13px;
}
.side-user .u-name { color: #fff; font-weight: 600; }
.side-user .u-role {
display: inline-block; margin-top: 3px; font-size: 11px; letter-spacing: .04em;
text-transform: uppercase; color: var(--accent); font-weight: 700;
}
.side-user button {
margin-top: 10px; width: 100%; background: rgba(255,255,255,.08);
color: #d7deea; border: none; padding: 8px; border-radius: 7px; cursor: pointer;
font-size: 13px;
}
.side-user button:hover { background: rgba(255,255,255,.16); }
/* ---------- Main ---------- */
.main { display: flex; flex-direction: column; min-width: 0; }
.content { flex: 1; padding: 30px 34px; max-width: 1180px; width: 100%; }
.page-head { margin-bottom: 22px; }
.page-head h1 { font-size: 24px; margin: 0 0 4px; letter-spacing: -.02em; }
.page-head p { margin: 0; color: var(--muted); }
.footer {
border-top: 1px solid var(--line);
padding: 14px 34px; color: var(--muted); font-size: 12.5px;
font-family: var(--mono); background: var(--surface);
}
/* ---------- Cards / panels ---------- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
}
.card + .card { margin-top: 20px; }
.card-head {
padding: 16px 20px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.card-head h2 { font-size: 16px; margin: 0; letter-spacing: -.01em; }
.card-body { padding: 18px 20px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex; align-items: center; gap: 7px; justify-content: center;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
padding: 8px 14px; border-radius: 8px; font-size: 14px; font-weight: 600;
cursor: pointer; transition: .12s; font-family: inherit;
}
.btn:hover { border-color: #c2cad4; background: #fbfcfd; }
.btn svg { width: 16px; height: 16px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); }
.btn-danger { background: #fff; border-color: #e6b9bd; color: var(--danger); }
.btn-danger:hover { background: var(--danger-soft); }
.btn-ghost { border-color: transparent; background: transparent; }
.btn-sm { padding: 5px 10px; font-size: 13px; }
.btn:disabled { opacity: .55; cursor: not-allowed; }
/* ---------- Forms ---------- */
label { display: block; font-size: 13px; font-weight: 600; color: #384250; margin-bottom: 6px; }
input[type=text], input[type=email], input[type=password], input[type=search],
input[type=datetime-local], textarea, select {
width: 100%; padding: 9px 11px; border: 1px solid var(--line);
border-radius: 8px; font-size: 14px; font-family: inherit; background: #fff; color: var(--text);
}
input:focus, textarea:focus, select:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
textarea { resize: vertical; min-height: 120px; font-family: var(--mono); font-size: 13px; }
.field { margin-bottom: 16px; }
.hint { font-size: 12.5px; color: var(--muted); margin-top: 6px; }
/* ---------- Tables ---------- */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); }
th { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; }
tbody tr:hover { background: #f8fafb; }
.mono { font-family: var(--mono); font-size: 12.5px; }
/* ---------- Badges ---------- */
.badge {
display: inline-block; padding: 2px 9px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: .02em;
}
.badge-pending { background: #fdf3e0; color: var(--warn); }
.badge-approved { background: #e6f4ec; color: var(--ok); }
.badge-denied { background: var(--danger-soft); color: var(--danger); }
.badge-admin { background: var(--accent-soft); color: var(--accent-ink); }
/* ---------- File tree ---------- */
.tree { list-style: none; margin: 0; padding: 0; }
.tree .row {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 8px; border: 1px solid transparent;
}
.tree .row:hover { background: #f6f8fa; border-color: var(--line); }
.tree .row .ic { width: 18px; height: 18px; flex: none; color: var(--muted); }
.tree .row .ic.folder { color: var(--clay); }
.tree .name { font-weight: 500; }
.tree .meta { color: var(--muted); font-size: 12.5px; margin-left: 6px; }
.tree .spacer { flex: 1; }
.tree .actions { display: flex; gap: 4px; opacity: 0; transition: .12s; }
.tree .row:hover .actions { opacity: 1; }
.tree ul { list-style: none; margin: 2px 0 2px 22px; padding-left: 12px; border-left: 1px dashed var(--line); }
/* ---------- Toast ---------- */
.toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 10px; z-index: 9999; }
.toast {
background: var(--ink); color: #fff; padding: 12px 16px; border-radius: 9px;
box-shadow: var(--shadow); font-size: 14px; max-width: 360px; animation: pop .18s ease;
}
.toast.ok { background: #14532d; }
.toast.err { background: #7f1d24; }
@keyframes pop { from { transform: translateY(8px); opacity: 0; } }
/* ---------- Modal ---------- */
.modal-back {
position: fixed; inset: 0; background: rgba(15,20,28,.5);
display: grid; place-items: center; z-index: 1000; padding: 20px;
}
.modal {
background: #fff; border-radius: 14px; width: 100%; max-width: 560px;
box-shadow: 0 20px 60px rgba(0,0,0,.3); overflow: hidden;
}
.modal.lg { max-width: 820px; }
.modal-head { padding: 18px 22px; border-bottom: 1px solid var(--line); font-weight: 700; font-size: 17px; }
.modal-body { padding: 22px; max-height: 70vh; overflow: auto; }
.modal-foot { padding: 16px 22px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; }
/* ---------- Login ---------- */
.auth-wrap { min-height: 100vh; display: grid; grid-template-columns: 1.1fr 1fr; }
.auth-hero {
background: radial-gradient(120% 120% at 0% 0%, #1c2a2e 0%, var(--ink) 55%);
color: #fff; padding: 56px; display: flex; flex-direction: column; justify-content: center;
}
.auth-hero .logo-lg {
width: 52px; height: 52px; border-radius: 13px;
background: linear-gradient(135deg, var(--accent), #2aa596);
display: grid; place-items: center; font-weight: 800; font-size: 24px; color:#fff;
}
.auth-hero .auth-logo {
display: inline-block; background: #fff; border-radius: 16px;
padding: 18px 22px; line-height: 0; max-width: 168px;
box-shadow: 0 2px 14px rgba(0,0,0,.22);
}
.auth-hero .auth-logo img { width: 100%; height: auto; display: block; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.auth-hero h1 { font-size: 34px; letter-spacing: -.03em; margin: 26px 0 0; line-height: 1.1; }
.auth-hero p { color: #9fb0b3; max-width: 400px; font-size: 15px; }
.auth-hero .feat { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
.auth-hero .feat div { display: flex; gap: 11px; align-items: center; color: #cdd8d9; font-size: 14px; }
.auth-hero .feat svg { width: 18px; height: 18px; color: var(--accent); flex: none; }
.auth-hero .foot { color: #6f7f82; font-size: 12px; font-family: var(--mono); }
.auth-panel { display: grid; place-items: center; padding: 40px; background: var(--surface); }
.auth-form { width: 100%; max-width: 360px; }
.auth-form h2 { font-size: 22px; margin: 0 0 4px; letter-spacing: -.02em; }
.auth-form .sub { color: var(--muted); margin: 0 0 26px; }
.auth-form .btn-primary { width: 100%; padding: 11px; font-size: 15px; }
.err-line { color: var(--danger); font-size: 13.5px; margin-top: 12px; min-height: 18px; }
.otp-input { letter-spacing: .5em; text-align: center; font-size: 20px; font-family: var(--mono); }
.empty { text-align: center; padding: 44px 20px; color: var(--muted); }
.empty svg { width: 40px; height: 40px; opacity: .4; margin-bottom: 10px; }
.qr-box { text-align: center; }
.qr-box img { width: 200px; height: 200px; border: 1px solid var(--line); border-radius: 12px; padding: 8px; background:#fff; }
.secret {
font-family: var(--mono); background: var(--canvas); padding: 8px 12px; border-radius: 8px;
display: inline-block; margin-top: 10px; font-size: 13px; word-break: break-all;
}
.placeholders { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.placeholders code {
font-family: var(--mono); font-size: 12px; background: var(--accent-soft);
color: var(--accent-ink); padding: 2px 8px; border-radius: 6px; cursor: pointer;
}
.img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px,1fr)); gap: 12px; }
.img-card { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background:#fff; }
.img-card img { width: 100%; height: 92px; object-fit: contain; background: #f4f6f8; }
.img-card .cap { padding: 8px; font-size: 12px; display: flex; justify-content: space-between; align-items: center; gap: 6px; }
.img-card .cap .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.toolbar .spacer { flex: 1; }
@media (max-width: 860px) {
.app { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; flex-direction: column; }
.nav { flex-direction: row; flex-wrap: wrap; }
.grid-2 { grid-template-columns: 1fr; }
.auth-wrap { grid-template-columns: 1fr; }
.auth-hero { display: none; }
}
/* ---- First-boot setup wizard ---- */
.setup-badge{
display:inline-block;
font-size:12px;
font-weight:600;
letter-spacing:.04em;
text-transform:uppercase;
color:var(--accent);
background:rgba(31,122,112,.10);
border:1px solid rgba(31,122,112,.28);
padding:5px 10px;
border-radius:999px;
margin-bottom:14px;
}
.pw-meter{
height:5px;
border-radius:999px;
background:#e6eaef;
margin:9px 0 7px;
overflow:hidden;
}
.pw-meter span{
display:block;
height:100%;
width:0;
border-radius:999px;
transition:width .18s ease, background .18s ease;
}
.pw-meter span.lvl-1{ background:#d9534f; }
.pw-meter span.lvl-2{ background:#e0a534; }
.pw-meter span.lvl-3{ background:#3f9d5a; }
.pw-meter span.lvl-4{ background:var(--accent); }
/* ---- Users tab helpers ---- */
.row-actions{ white-space:nowrap; display:flex; gap:6px; justify-content:flex-end; }
td.muted{ color:var(--muted); text-align:center; padding:18px 0; }
/* ---- Locked (not yet approved) folders on View Data ---- */
.tree .row.locked .name { color: var(--muted); }
.tree .row.locked .ic svg { color: var(--muted); }
.locked-note {
font-size: 12px; color: var(--muted);
border: 1px dashed var(--line); border-radius: 999px; padding: 2px 9px; margin-left: 4px;
}
/* ---- Version Control ---- */
.ver-list { display: flex; flex-direction: column; gap: 14px; }
.ver-item { border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; background: var(--surface); }
.ver-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.ver-tag {
font-family: var(--mono); font-size: 13px; font-weight: 700; color: #fff;
background: var(--accent); border-radius: 7px; padding: 3px 9px;
}
.ver-title { font-weight: 650; font-size: 15px; }
.ver-date { color: var(--muted); font-size: 13px; margin-left: auto; font-family: var(--mono); }
.ver-notes { margin-top: 10px; color: var(--ink); font-size: 14px; white-space: pre-wrap; line-height: 1.55; }
.notes-cell { color: var(--muted); font-size: 13px; max-width: 260px; }
/* Long page headings should wrap cleanly */
.page-head h1 { max-width: 900px; line-height: 1.2; text-wrap: balance; }
/* ---- Mail configuration + mail log ---- */
.cfg-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.cfg-grid .field.wide { grid-column: 1 / -1; }
.cfg-status { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 14px; }
.dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.dot.on { background: #3f9d5a; }
.dot.off { background: #c9ced6; }
.badge-delivered { background: rgba(63,157,90,.12); color: #2f7d46; border-color: rgba(63,157,90,.3); }
.badge-queued { background: rgba(224,165,52,.14); color: #8a6413; border-color: rgba(224,165,52,.35); }
.badge-failed { background: rgba(217,83,79,.12); color: #a8322e; border-color: rgba(217,83,79,.3); }
.mail-preview { color: var(--muted); font-size: 12.5px; max-width: 340px; }
@media (max-width: 860px) { .cfg-grid { grid-template-columns: 1fr; } }
/* ---- Warning banner (e.g. notifications cannot be delivered) ---- */
.warn-banner {
border: 1px solid rgba(224,165,52,.45);
background: rgba(224,165,52,.10);
color: #6d5210;
border-radius: 11px;
padding: 13px 16px;
font-size: 13.5px;
line-height: 1.55;
margin-bottom: 16px;
}
.warn-banner a { color: var(--accent); font-weight: 600; margin-left: 6px; }
.req-star { color: #c0392b; font-weight: 700; margin-left: 2px; }
/* ---- Folder dates on View Data ---- */
.folder-dates {
display: flex; flex-wrap: wrap; gap: 10px 22px;
margin: 2px 0 8px 34px; padding: 7px 12px;
border-left: 2px solid var(--line);
background: rgba(19,24,32,.02);
border-radius: 0 8px 8px 0;
}
.date-item { display: flex; align-items: baseline; gap: 7px; }
.date-label {
font-size: 10.5px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted);
}
.date-value { font-family: var(--mono); font-size: 12.5px; color: var(--ink); }
.date-value.muted-value { color: var(--muted); font-style: italic; }
.date-value.date-expired { color: #a8322e; font-weight: 600; }
/* ---- Collapsible tree in Data Management ---- */
.tw { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.tw-toolbar {
display: flex; gap: 8px; align-items: center;
padding: 8px 12px; border-bottom: 1px solid var(--line); background: rgba(19,24,32,.02);
}
.tw-toolbar .hint { margin-left: auto; }
.caret {
width: 22px; height: 22px; flex: none; display: inline-flex;
align-items: center; justify-content: center; cursor: pointer;
border-radius: 6px; color: var(--muted); background: transparent; border: none;
transition: transform .15s ease, background .15s ease;
}
.caret:hover { background: rgba(19,24,32,.06); color: var(--ink); }
.caret svg { width: 13px; height: 13px; }
.caret.open { transform: rotate(90deg); }
.caret.leaf { visibility: hidden; cursor: default; }
.child-count {
font-size: 11.5px; color: var(--muted); background: rgba(19,24,32,.05);
border-radius: 999px; padding: 1px 8px; margin-left: 6px;
}
.fdate { font-family: var(--mono); font-size: 11.5px; color: var(--muted); margin-left: 8px; }
.fdate.missing { color: #b3541e; font-style: italic; }
/* ---- Access countdown on View Data ---- */
.access-timer {
font-family: var(--mono); font-size: 12.5px;
padding: 1px 9px; border-radius: 999px; border: 1px solid transparent;
display: inline-block;
}
.access-perm { color: #2f7d46; background: rgba(63,157,90,.10); border-color: rgba(63,157,90,.28); }
.access-live { color: #1f5f8b; background: rgba(31,95,139,.10); border-color: rgba(31,95,139,.26); }
.access-soon { color: #8a6413; background: rgba(224,165,52,.14); border-color: rgba(224,165,52,.38); }
.access-denied { color: #a8322e; background: rgba(217,83,79,.10); border-color: rgba(217,83,79,.28); font-weight: 600; }
/* ---- Radio list (validity choices) ---- */
.radio-group { display: flex; flex-direction: column; gap: 2px; }
.radio-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px;
cursor: pointer; font-size: 14px; transition: background .12s ease, border-color .12s ease;
}
.radio-row:hover { background: rgba(19,24,32,.03); }
.radio-row input { width: auto; margin: 0; accent-color: var(--accent); }
.radio-row:has(input:checked) { border-color: var(--accent); background: rgba(31,122,112,.07); }
/* ---- Storage page ---- */
.usage-bar {
height: 22px; border-radius: 999px; background: #e9edf2;
overflow: hidden; border: 1px solid var(--line);
}
.usage-bar.sm { height: 8px; }
.usage-bar span { display: block; height: 100%; transition: width .3s ease; }
.bar-ok { background: linear-gradient(90deg, var(--accent), #2f9a8d); }
.bar-warn { background: linear-gradient(90deg, #d9a13a, #e0b45b); }
.bar-crit { background: linear-gradient(90deg, #c0392b, #d9534f); }
.usage-legend {
display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 12px;
font-size: 13.5px; color: var(--muted); align-items: center;
}
.usage-legend strong { color: var(--ink); font-family: var(--mono); }
.usage-pct { margin-left: auto; font-weight: 600; color: var(--accent); }
.usage-pct.warn { color: #8a6413; }
.usage-pct.crit { color: #a8322e; }
/* ---- Sidebar logo ----
The artwork is dark-on-white, so it sits on a light panel to stay legible
against the dark sidebar. */
.brand-logo {
display: block;
background: #fff;
border-radius: 10px;
padding: 10px 12px;
line-height: 0;
box-shadow: 0 1px 0 rgba(255,255,255,.06), 0 2px 10px rgba(0,0,0,.18);
transition: box-shadow .15s ease, transform .15s ease;
}
.brand-logo:hover { box-shadow: 0 1px 0 rgba(255,255,255,.10), 0 4px 16px rgba(0,0,0,.26); }
.brand-logo img { width: 100%; height: auto; display: block; }
.brand .tag { margin-top: 10px; }
@media (max-width: 860px) {
.brand-logo { max-width: 260px; }
}
/* ---- User avatar (sidebar) ---- */
.side-user .u-identity { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.u-avatar {
width: 38px; height: 38px; border-radius: 50%; flex: none; overflow: hidden;
display: grid; place-items: center; background: rgba(255,255,255,.10);
border: 1px solid rgba(255,255,255,.14);
}
.u-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.u-identity .u-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.u-identity .u-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.avatar-initials { font-weight: 700; font-size: 13px; color: #cdd6e2; letter-spacing: .02em; }
/* ---- Avatar on the account page ---- */
.profile-row { display: flex; align-items: center; gap: 18px; }
.avatar-lg {
width: 84px; height: 84px; border-radius: 50%; flex: none; overflow: hidden;
display: grid; place-items: center; background: #eef1f5; border: 1px solid var(--line);
}
.avatar-lg img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-lg .avatar-initials { font-size: 28px; color: var(--muted); }
.divider { height: 1px; background: var(--line); margin: 18px 0; }
/* ---- Upload progress ---- */
.upload-progress { margin: 6px 0 4px; }
.upload-bar {
height: 12px; border-radius: 999px; background: #e9edf2;
overflow: hidden; border: 1px solid var(--line);
}
.upload-bar span {
display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--accent), #2f9a8d);
transition: width .2s ease;
}
.upload-progress .hint { margin-top: 6px; }
File diff suppressed because it is too large Load Diff
+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') };
}
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Legislation · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('legislation'); if (!shell) return;
const { isAdmin, content } = shell;
function entryHtml(e) {
return `
<div class="ver-item">
<div class="ver-head">
${e.reference ? `<span class="ver-tag">${esc(e.reference)}</span>` : ''}
<span class="ver-title">${esc(e.title)}</span>
${e.effective_date ? `<span class="ver-date">In force: ${esc(fmtDate(e.effective_date))}</span>` : ''}
</div>
${e.summary ? `<div class="ver-notes">${esc(e.summary)}</div>` : ''}
${e.link_url ? `<div style="margin-top:10px">
<a class="btn btn-sm" href="${esc(e.link_url)}" target="_blank" rel="noopener noreferrer">
Read the full text</a></div>` : ''}
</div>`;
}
async function load() {
let entries = [];
try { entries = (await api('/api/legislation')).entries; }
catch (e) { toast(e.message, 'err'); }
content.innerHTML = `
<div class="page-head">
<h1>Legislation</h1>
<p>Laws, regulations and internal rules governing the handling of this data.${
isAdmin ? ' Entries are managed in Data Management → Legislation.' : ''}</p>
</div>
<div class="card">
<div class="card-body">
${entries.length
? `<div class="ver-list">${entries.map(entryHtml).join('')}</div>`
: `<div class="empty">${ICON.legislation}<div>No legislation has been published yet.</div></div>`}
</div>
</div>`;
}
load();
})();
</script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="auth-wrap">
<div class="auth-hero">
<div>
<div class="auth-logo"><img src="/assets/infosec-logo.png" alt="Infosec"></div>
<h1 class="visually-hidden">Infosec</h1>
</div>
</div>
<div class="auth-panel">
<div class="auth-form">
<!-- Step 1: credentials -->
<div id="step-cred">
<h2>Sign in</h2>
<p class="sub">Use your Infosec account to continue.</p>
<div class="field">
<label>Username or email</label>
<input type="text" id="username" autocomplete="username" autofocus>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password">
</div>
<button class="btn btn-primary" id="loginBtn">Sign in</button>
<div class="err-line" id="err"></div>
</div>
<!-- Step 2: MFA -->
<div id="step-mfa" style="display:none">
<h2>Two-factor code</h2>
<p class="sub">Enter the 6-digit code from your authenticator app.</p>
<div class="field">
<input type="text" id="otp" class="otp-input" inputmode="numeric" maxlength="6" placeholder="••••••">
</div>
<button class="btn btn-primary" id="verifyBtn">Verify &amp; sign in</button>
<div class="err-line" id="err2"></div>
<p class="hint" style="margin-top:14px"><a href="#" id="backLink">← Use a different account</a></p>
</div>
</div>
</div>
</div>
<script src="/js/app.js"></script>
<script>
const $ = (id) => document.getElementById(id);
let creds = {};
async function doLogin(token) {
$('err').textContent = ''; $('err2').textContent = '';
try {
const body = { username: creds.username, password: creds.password };
if (token) body.token = token;
const r = await api('/api/auth/login', { method: 'POST', body });
if (r.mfa_required) {
$('step-cred').style.display = 'none';
$('step-mfa').style.display = 'block';
$('otp').focus();
return;
}
location.href = '/view-data.html';
} catch (e) {
(token ? $('err2') : $('err')).textContent = e.message;
}
}
$('loginBtn').onclick = () => {
creds = { username: $('username').value.trim(), password: $('password').value };
if (!creds.username || !creds.password) { $('err').textContent = 'Enter your username and password.'; return; }
doLogin();
};
$('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('loginBtn').click(); });
$('verifyBtn').onclick = () => doLogin($('otp').value.trim());
$('otp').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('verifyBtn').click(); });
$('backLink').onclick = (e) => { e.preventDefault(); location.reload(); };
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logs · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('logs'); if (!shell) return;
const { content } = shell;
let meta = { actions: [], pages: [] };
content.innerHTML = `
<div class="page-head">
<h1>Logs</h1>
<p>Every action on the View Data and Data Management pages is recorded here.</p>
</div>
<div class="card">
<div class="card-head">
<h2>Audit trail</h2>
<div class="toolbar" style="margin:0">
<button class="btn" id="exportBtn">${ICON.download} Export CSV</button>
<button class="btn btn-primary" id="emailBtn">${ICON.mail} Email logs</button>
</div>
</div>
<div class="card-body">
<div class="toolbar">
<input type="search" id="q" placeholder="Search actor or detail…" style="max-width:240px">
<select id="action" style="max-width:200px"><option value="">All actions</option></select>
<select id="page" style="max-width:200px"><option value="">All pages</option></select>
<button class="btn" id="applyBtn">Filter</button>
<span class="spacer"></span>
<span class="hint" id="count"></span>
</div>
<div id="table"></div>
</div>
</div>`;
const $ = (id) => document.getElementById(id);
function params() {
const p = new URLSearchParams();
if ($('q').value.trim()) p.set('q', $('q').value.trim());
if ($('action').value) p.set('action', $('action').value);
if ($('page').value) p.set('page', $('page').value);
return p;
}
async function load() {
const data = await api('/api/logs?' + params().toString());
meta = data;
// populate selects once
if ($('action').options.length <= 1) {
$('action').innerHTML = '<option value="">All actions</option>' +
data.actions.map((a) => `<option value="${esc(a)}">${esc(a)}</option>`).join('');
$('page').innerHTML = '<option value="">All pages</option>' +
data.pages.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
}
$('count').textContent = `${data.logs.length} of ${data.total} entries`;
$('table').innerHTML = data.logs.length ? `
<table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Page</th><th>Detail</th><th>IP</th></tr></thead>
<tbody>${data.logs.map((l) => `<tr>
<td class="mono">${esc(fmtDateTime(l.ts))}</td>
<td>${esc(l.actor)}</td>
<td><span class="badge badge-admin">${esc(l.action)}</span></td>
<td>${esc(l.page || '—')}</td>
<td>${esc(l.detail || '')}</td>
<td class="mono">${esc(l.ip || '')}</td>
</tr>`).join('')}</tbody></table>` :
`<div class="empty">${ICON.logs}<div>No log entries match your filter.</div></div>`;
}
$('applyBtn').onclick = load;
$('q').addEventListener('keydown', (e) => { if (e.key === 'Enter') load(); });
$('action').onchange = load;
$('page').onchange = load;
$('exportBtn').onclick = () => { location.href = '/api/logs/export?' + params().toString(); };
$('emailBtn').onclick = () => {
modal('Email audit logs', `
<p>Send the currently filtered logs as a CSV attachment.</p>
<div class="field">
<label>Recipient (leave blank to send to all admins)</label>
<input type="email" id="to" placeholder="security@yourdomain.com">
</div>`, {
buttons: [{ label: 'Cancel' }, { label: 'Send', className: 'btn-primary', onClick: async (back) => {
const body = Object.fromEntries(params());
const to = back.querySelector('#to').value.trim();
if (to) body.to = to;
try {
const r = await api('/api/logs/email', { method: 'POST', body });
back.remove();
toast(r.delivered ? `Sent ${r.count} entries` : `Queued ${r.count} entries (SMTP not configured)`, 'ok');
} catch (e) { toast(e.message, 'err'); }
return false;
} }],
});
};
load();
})();
</script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>First-time setup · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="auth-wrap">
<div class="auth-hero">
<div>
<div class="auth-logo"><img src="/assets/infosec-logo.png" alt="Infosec"></div>
<h1>Welcome to Infosec</h1>
<p>Let's create the administrator account for this installation. This only happens once.</p>
<div class="feat">
<div>%SHIELD% This account has full administrative rights</div>
<div>%CHECK% You can add two-factor authentication right after</div>
<div>%LOGS% Everything from here on is recorded in the audit log</div>
</div>
</div>
<div class="foot">© 2026 Martinhal IT - Joao Vaz - Version 2.3</div>
</div>
<div class="auth-panel">
<div class="auth-form">
<div id="step-form">
<div class="setup-badge">Step 1 of 1 · First-time setup</div>
<h2>Create administrator</h2>
<p class="sub">These are the credentials you will use to sign in from now on.</p>
<div class="field">
<label>Username</label>
<input type="text" id="username" autocomplete="username" autofocus placeholder="admin">
<span class="hint">332 characters. Letters, numbers, dot, underscore or hyphen.</span>
</div>
<div class="field">
<label>Email address <span class="req-star">*</span></label>
<input type="email" id="email" required autocomplete="email" placeholder="admin@yourdomain.com">
<span class="hint">Required. Approval requests and system notifications are sent here.</span>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="new-password">
<div class="pw-meter"><span id="pwBar"></span></div>
<span class="hint" id="pwHint">At least 10 characters, including a letter and a number.</span>
</div>
<div class="field">
<label>Confirm password</label>
<input type="password" id="confirm" autocomplete="new-password">
</div>
<button class="btn btn-primary" id="createBtn">Create account &amp; continue</button>
<div class="err-line" id="err"></div>
</div>
<div id="step-done" style="display:none">
<h2>All set</h2>
<p class="sub">Your administrator account has been created and you are now signed in.</p>
<p class="hint">Taking you to Infosec…</p>
</div>
</div>
</div>
</div>
<script src="/js/app.js"></script>
<script>
document.querySelector('.auth-hero').innerHTML =
document.querySelector('.auth-hero').innerHTML
.replace('%SHIELD%', ICON.shield).replace('%CHECK%', ICON.check).replace('%LOGS%', ICON.logs);
const $ = (id) => document.getElementById(id);
// If setup was already completed (e.g. someone bookmarked this page), leave.
(async () => {
try {
const s = await api('/api/setup/status');
if (!s.needs_setup) location.href = '/login.html';
} catch (_) { /* ignore — the server will reject the POST anyway */ }
})();
// Lightweight strength indicator (guidance only; the server enforces the rules).
function strength(p) {
let s = 0;
if (p.length >= 10) s++;
if (p.length >= 14) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/[0-9]/.test(p)) s++;
if (/[^A-Za-z0-9]/.test(p)) s++;
return Math.min(s, 4);
}
$('password').addEventListener('input', () => {
const s = strength($('password').value);
const bar = $('pwBar');
bar.className = 'lvl-' + s;
bar.style.width = (s * 25) + '%';
});
async function create() {
$('err').textContent = '';
const payload = {
username: $('username').value.trim(),
email: $('email').value.trim(),
password: $('password').value,
confirm: $('confirm').value,
};
if (!payload.email) { $('err').textContent = 'An email address is required.'; return; }
if (!payload.username || !payload.password) {
$('err').textContent = 'Please fill in every field.'; return;
}
if (payload.password !== payload.confirm) {
$('err').textContent = 'The two passwords do not match.'; return;
}
$('createBtn').disabled = true;
$('createBtn').textContent = 'Creating…';
try {
await api('/api/setup', { method: 'POST', body: payload });
$('step-form').style.display = 'none';
$('step-done').style.display = 'block';
setTimeout(() => { location.href = '/view-data.html'; }, 1200);
} catch (e) {
$('err').textContent = e.message;
$('createBtn').disabled = false;
$('createBtn').textContent = 'Create account & continue';
}
}
$('createBtn').onclick = create;
$('confirm').addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); });
</script>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Storage · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('storage'); if (!shell) return;
const { content } = shell;
let data = null;
let sortBy = 'total'; // 'total' | 'name'
function pct(part, whole) {
if (!whole) return 0;
return Math.round((part / whole) * 1000) / 10;
}
function barClass(p) {
if (p >= 90) return 'bar-crit';
if (p >= 75) return 'bar-warn';
return 'bar-ok';
}
function diskCard() {
const d = data.disk;
const p = d.percent_used;
return `
<div class="card">
<div class="card-head"><h3>Server disk</h3>
<span class="hint">Filesystem holding the uploads directory</span></div>
<div class="card-body">
<div class="usage-bar"><span class="${barClass(p)}" style="width:${Math.min(p, 100)}%"></span></div>
<div class="usage-legend">
<span><strong>${fmtBytes(d.used)}</strong> used</span>
<span><strong>${fmtBytes(d.free)}</strong> free</span>
<span><strong>${fmtBytes(d.total)}</strong> total</span>
<span class="usage-pct ${p >= 90 ? 'crit' : (p >= 75 ? 'warn' : '')}">${p}% used</span>
</div>
${p >= 90 ? `<div class="warn-banner" style="margin-top:14px">
<strong>The disk is nearly full.</strong> Uploads will start failing when it runs out.
</div>` : ''}
</div>
</div>`;
}
function appCard() {
const a = data.app;
const rows = [
['Uploaded files', a.uploads_bytes, `${a.uploads_files} file${a.uploads_files === 1 ? '' : 's'}`, data.paths.uploads],
['Brand images', a.brand_bytes, `${a.brand_files} file${a.brand_files === 1 ? '' : 's'}`, data.paths.brand],
['Database', a.database_bytes, 'accounts, folders, logs', data.paths.database],
].map(([label, bytes, note, where]) => `
<tr>
<td><strong>${esc(label)}</strong><div class="hint mono">${esc(where)}</div></td>
<td class="mono">${fmtBytes(bytes)}</td>
<td class="hint">${esc(note)}</td>
<td style="width:34%">
<div class="usage-bar sm"><span class="bar-ok" style="width:${pct(bytes, a.total_bytes)}%"></span></div>
</td>
</tr>`).join('');
return `
<div class="card">
<div class="card-head"><h3>Used by Infosec</h3>
<span class="hint">${fmtBytes(data.app.total_bytes)} in total</span></div>
<div class="card-body">
<table><tbody>${rows}</tbody></table>
</div>
</div>`;
}
function foldersCard() {
const folders = [...data.folders];
if (sortBy === 'total') folders.sort((a, b) => b.total_bytes - a.total_bytes || a.path.localeCompare(b.path));
else folders.sort((a, b) => a.path.localeCompare(b.path));
const biggest = folders.reduce((m, f) => Math.max(m, f.total_bytes), 0);
const rows = folders.map((f) => `
<tr>
<td><strong>${esc(f.name)}</strong>${f.depth
? `<div class="hint">${esc(f.path)}</div>` : ''}</td>
<td class="mono">${fmtBytes(f.total_bytes)}</td>
<td class="mono hint">${fmtBytes(f.own_bytes)}</td>
<td class="hint">${f.total_files} file${f.total_files === 1 ? '' : 's'}</td>
<td style="width:30%">
<div class="usage-bar sm"><span class="bar-ok" style="width:${pct(f.total_bytes, biggest)}%"></span></div>
</td>
</tr>`).join('');
const rootRow = data.root_files.count ? `
<tr>
<td><strong>(files outside any folder)</strong></td>
<td class="mono">${fmtBytes(data.root_files.bytes)}</td>
<td class="mono hint">${fmtBytes(data.root_files.bytes)}</td>
<td class="hint">${data.root_files.count} file${data.root_files.count === 1 ? '' : 's'}</td>
<td><div class="usage-bar sm"><span class="bar-ok" style="width:${pct(data.root_files.bytes, biggest)}%"></span></div></td>
</tr>` : '';
return `
<div class="card">
<div class="card-head"><h3>Space used per folder</h3>
<div class="row-actions" style="justify-content:flex-end">
<button class="btn btn-sm ${sortBy === 'total' ? 'btn-primary' : ''}" id="sortSize">Largest first</button>
<button class="btn btn-sm ${sortBy === 'name' ? 'btn-primary' : ''}" id="sortName">By name</button>
</div>
</div>
<div class="card-body">
<p class="hint" style="margin:0 0 12px">
<strong>Total</strong> includes everything nested inside the folder;
<strong>own</strong> counts only files sitting directly in it.
</p>
<table>
<thead><tr><th>Folder</th><th>Total</th><th>Own</th><th>Files</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="5" class="muted">No folders yet.</td></tr>'}${rootRow}</tbody>
</table>
</div>
</div>`;
}
function integrityCard() {
const i = data.integrity;
if (!i.missing.length && !i.orphans.length) {
return `<div class="card"><div class="card-head"><h3>Consistency</h3></div>
<div class="card-body"><p class="hint" style="margin:0">
Every recorded file is present on disk, and nothing on disk is unaccounted for.
</p></div></div>`;
}
return `
<div class="card">
<div class="card-head"><h3>Consistency</h3></div>
<div class="card-body">
${i.missing.length ? `
<div class="warn-banner">
<strong>${i.missing.length} recorded file(s) are missing from disk.</strong>
They will appear in listings but cannot be downloaded:
${esc(i.missing.slice(0, 5).map((m) => m.name).join(', '))}${i.missing.length > 5 ? '…' : ''}
</div>` : ''}
${i.orphans.length ? `
<div class="warn-banner">
<strong>${i.orphans.length} file(s) on disk are not referenced by any record</strong>
(${fmtBytes(i.orphan_bytes)}). These are safe to remove.
<button class="btn btn-sm" id="cleanOrphans" style="margin-left:8px">Reclaim space</button>
</div>` : ''}
</div>
</div>`;
}
function render() {
content.innerHTML = `
<div class="page-head">
<h1>Storage</h1>
<p>Disk space on this server and how much of it each folder is using.</p>
</div>
${diskCard()}
${appCard()}
${foldersCard()}
${integrityCard()}
<p class="hint" style="margin-top:14px">
Measured ${esc(fmtDateTime(data.generated_at))} ·
<a href="#" id="refresh">Refresh</a>
</p>`;
document.getElementById('sortSize').onclick = () => { sortBy = 'total'; render(); };
document.getElementById('sortName').onclick = () => { sortBy = 'name'; render(); };
document.getElementById('refresh').onclick = (e) => { e.preventDefault(); load(); };
const clean = document.getElementById('cleanOrphans');
if (clean) clean.onclick = async () => {
if (!confirm('Permanently delete files on disk that no record points at?')) return;
try {
const r = await api('/api/storage/cleanup-orphans', { method: 'POST' });
toast(`Reclaimed ${fmtBytes(r.bytes)} from ${r.removed} file(s)`, 'ok');
load();
} catch (err) { toast(err.message, 'err'); }
};
}
async function load() {
try {
data = await api('/api/storage');
render();
} catch (e) {
content.innerHTML = `<div class="page-head"><h1>Storage</h1></div>
<div class="card"><div class="card-body"><p>${esc(e.message)}</p></div></div>`;
}
}
load();
})();
</script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Version Control · Infosec</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<script src="/js/app.js"></script>
<script>
(async () => {
const shell = await buildShell('version'); if (!shell) return;
const { isAdmin, content } = shell;
function entryHtml(e) {
return `
<div class="ver-item">
<div class="ver-head">
<span class="ver-tag">${esc(e.version)}</span>
${e.title ? `<span class="ver-title">${esc(e.title)}</span>` : ''}
${e.released_on ? `<span class="ver-date">${esc(fmtDate(e.released_on))}</span>` : ''}
</div>
${e.notes ? `<div class="ver-notes">${esc(e.notes)}</div>` : ''}
</div>`;
}
async function load() {
let entries = [];
try { entries = (await api('/api/versions')).entries; }
catch (e) { toast(e.message, 'err'); }
content.innerHTML = `
<div class="page-head">
<h1>Version Control for Infosec</h1>
<p>Release history for this system. Entries are managed in Data Management → Version Control.</p>
</div>
<div class="card">
<div class="card-body">
${entries.length
? `<div class="ver-list">${entries.map(entryHtml).join('')}</div>`
: `<div class="empty">${ICON.version}<div>No version entries have been published yet.</div></div>`}
</div>
</div>`;
}
load();
})();
</script>
</body>
</html>
+214
View File
@@ -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>