Public Access
1341 lines
51 KiB
JavaScript
1341 lines
51 KiB
JavaScript
/* Visitor sign in — admin console. */
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||
|
||
let me = null; // the signed in admin
|
||
let sites = [];
|
||
let hosts = [];
|
||
let activeSiteId = 'all'; // which site the console is showing
|
||
|
||
/* ------------------------------------------------------------ plumbing */
|
||
|
||
async function api(path, { method = 'GET', body } = {}) {
|
||
const res = await fetch(`/admin/api${path}`, {
|
||
method,
|
||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
// The session lapsed or was signed out in another tab.
|
||
if (res.status === 401 || data.mustChangePassword) {
|
||
toLogin();
|
||
}
|
||
const error = new Error(data.error || `Request failed (${res.status}).`);
|
||
error.payload = data;
|
||
error.status = res.status;
|
||
throw error;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/** Adds the site the console is currently scoped to. */
|
||
function scoped(path) {
|
||
if (activeSiteId === 'all') return path;
|
||
return path + (path.includes('?') ? '&' : '?') + `siteId=${activeSiteId}`;
|
||
}
|
||
|
||
let toastTimer = null;
|
||
function toast(message, bad = false) {
|
||
const el = $('#toast');
|
||
el.textContent = message;
|
||
el.className = bad ? 'toast bad' : 'toast';
|
||
el.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
el.hidden = true;
|
||
}, 5500);
|
||
}
|
||
|
||
const esc = (v) =>
|
||
String(v ?? '').replace(/[&<>"']/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||
);
|
||
|
||
const stamp = (iso) =>
|
||
iso
|
||
? new Date(iso).toLocaleString('en-AU', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
})
|
||
: '—';
|
||
|
||
function table(headings, rows, emptyMessage) {
|
||
if (!rows.length) return `<p class="empty">${esc(emptyMessage)}</p>`;
|
||
return `<table>
|
||
<thead><tr>${headings.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead>
|
||
<tbody>${rows.join('')}</tbody>
|
||
</table>`;
|
||
}
|
||
|
||
function field(label, name, value = '', type = 'text') {
|
||
return `<label class="modal-field"><span>${esc(label)}</span>
|
||
<input name="${name}" type="${type}" value="${esc(value)}"></label>`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------ session */
|
||
// Signing in happens on /admin/login, a page of its own. If the session is gone
|
||
// or expires mid-use, go back there rather than trying to render a form here.
|
||
|
||
function toLogin() {
|
||
window.location.href = '/admin/login';
|
||
}
|
||
|
||
$('#logout').addEventListener('click', async () => {
|
||
await api('/logout', { method: 'POST' }).catch(() => {});
|
||
toLogin();
|
||
});
|
||
|
||
/* ---------------------------------------------------------------- tabs */
|
||
|
||
$$('.tab').forEach((tab) => {
|
||
tab.addEventListener('click', () => {
|
||
$$('.tab').forEach((t) => t.classList.toggle('on', t === tab));
|
||
$$('.panel').forEach((p) => p.classList.toggle('on', p.id === `panel-${tab.dataset.tab}`));
|
||
loadTab(tab.dataset.tab);
|
||
});
|
||
});
|
||
|
||
function currentTab() {
|
||
return $('.tab.on')?.dataset.tab || 'onsite';
|
||
}
|
||
|
||
function loadTab(name) {
|
||
const loaders = {
|
||
onsite: loadOnsite,
|
||
log: loadLog,
|
||
recurring: loadRecurring,
|
||
hosts: loadHosts,
|
||
sites: loadSites,
|
||
admins: loadAdmins,
|
||
system: loadSystem,
|
||
};
|
||
loaders[name]?.().catch((err) => toast(err.message, true));
|
||
}
|
||
|
||
$('#site-filter').addEventListener('change', (event) => {
|
||
activeSiteId = event.target.value;
|
||
$('#csv-link').href = scoped('/admin/api/visits.csv');
|
||
loadAlerts();
|
||
loadTab(currentTab());
|
||
});
|
||
|
||
function siteName(id) {
|
||
return sites.find((s) => s.id === id)?.name || '—';
|
||
}
|
||
|
||
/* ------------------------------------------------------------- alerts */
|
||
|
||
async function loadAlerts() {
|
||
const data = await api(scoped('/alerts'));
|
||
const total = data.expired.length + data.expiring.length;
|
||
const count = $('#alert-count');
|
||
count.hidden = total === 0;
|
||
count.textContent = total;
|
||
|
||
const banner = $('#expiry-banner');
|
||
if (!total) {
|
||
banner.hidden = true;
|
||
} else {
|
||
const parts = [];
|
||
if (data.expired.length) parts.push(`${data.expired.length} expired`);
|
||
if (data.expiring.length) parts.push(`${data.expiring.length} expiring within ${data.warningDays} days`);
|
||
banner.hidden = false;
|
||
banner.className = data.expired.length ? 'banner bad' : 'banner';
|
||
banner.textContent = `WWCC / VIT checks need attention: ${parts.join(', ')}.`;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function expiryCell(expiry, checkExpiry) {
|
||
if (!checkExpiry) return '<span class="pill off">No date</span>';
|
||
if (expiry.status === 'expired') {
|
||
return `<span class="pill bad">Expired ${Math.abs(expiry.daysLeft)}d ago</span>`;
|
||
}
|
||
if (expiry.status === 'expiring') {
|
||
return `<span class="pill warn">${expiry.daysLeft}d left</span>`;
|
||
}
|
||
return `<span class="mono">${esc(checkExpiry)}</span>`;
|
||
}
|
||
|
||
/* -------------------------------------------------------------- on site */
|
||
|
||
async function loadOnsite() {
|
||
const rows = await api(scoped('/onsite'));
|
||
$('#onsite-count').textContent = rows.length;
|
||
const showSite = activeSiteId === 'all' && sites.length > 1;
|
||
|
||
$('#onsite-table').innerHTML = table(
|
||
['', 'Visitor', showSite ? 'Site' : 'Visiting', ...(showSite ? ['Visiting'] : []), 'Check', 'Contact', 'Signed in', ''],
|
||
rows.map(
|
||
(v) => `<tr>
|
||
<td>${v.hasPhoto ? `<img class="thumb" src="/admin/api/photo/${v.id}" alt="">` : ''}</td>
|
||
<td><strong>${esc(v.firstName)} ${esc(v.lastName)}</strong>
|
||
${v.visitorType === 'frequent' ? '<span class="pill">Recurring</span>' : ''}</td>
|
||
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
|
||
<td>${esc(v.hostName)}</td>
|
||
<td>${v.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
|
||
<td>${esc(v.phone || v.email || '—')}</td>
|
||
<td class="mono">${stamp(v.signedInAt)}</td>
|
||
<td class="actions">
|
||
<button class="ghost" data-badge="${v.id}">Badge</button>
|
||
<button class="ghost" data-signout="${v.id}">Sign out</button>
|
||
</td>
|
||
</tr>`
|
||
),
|
||
'Nobody is signed in right now.'
|
||
);
|
||
|
||
$$('[data-signout]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
await api(`/visits/${btn.dataset.signout}/signout`, { method: 'POST' });
|
||
toast('Signed out.');
|
||
loadOnsite();
|
||
})
|
||
);
|
||
$$('[data-badge]').forEach((btn) =>
|
||
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
|
||
);
|
||
}
|
||
|
||
$('#refresh-onsite').addEventListener('click', () => loadOnsite());
|
||
|
||
/* ------------------------------------------------------------ visit log */
|
||
|
||
async function loadLog() {
|
||
const params = new URLSearchParams();
|
||
if ($('#log-from').value) params.set('from', $('#log-from').value);
|
||
if ($('#log-to').value) params.set('to', $('#log-to').value);
|
||
if ($('#log-q').value.trim()) params.set('q', $('#log-q').value.trim());
|
||
|
||
const rows = await api(scoped(`/visits?${params}`));
|
||
const showSite = activeSiteId === 'all' && sites.length > 1;
|
||
|
||
$('#log-table').innerHTML = table(
|
||
[...(showSite ? ['Site'] : []), 'Visitor', 'Visiting', 'Check', 'Contact', 'In', 'Out', 'Photo'],
|
||
rows.map(
|
||
(v) => `<tr>
|
||
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
|
||
<td>${esc(v.firstName)} ${esc(v.lastName)}</td>
|
||
<td>${esc(v.hostName)}</td>
|
||
<td>${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
|
||
<td>${esc(v.phone || v.email || '—')}</td>
|
||
<td class="mono">${stamp(v.signedInAt)}</td>
|
||
<td class="mono">${
|
||
v.signedOutAt
|
||
? `${stamp(v.signedOutAt)}${v.signedOutBy && v.signedOutBy !== 'visitor' ? ` <span class="pill out">${esc(v.signedOutBy)}</span>` : ''}`
|
||
: '<span class="pill">On site</span>'
|
||
}</td>
|
||
<td>${v.hasPhoto ? `<a class="ghost" href="/admin/api/photo/${v.id}" target="_blank" rel="noopener">View</a>` : '—'}</td>
|
||
</tr>`
|
||
),
|
||
'No visits match those filters.'
|
||
);
|
||
}
|
||
|
||
$('#log-search').addEventListener('click', () => loadLog());
|
||
$('#log-q').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') loadLog();
|
||
});
|
||
|
||
/* ------------------------------------------------- recurring visitors */
|
||
|
||
async function loadRecurring() {
|
||
const [rows, alerts] = await Promise.all([api(scoped('/frequent')), loadAlerts()]);
|
||
|
||
$('#expiry-summary').innerHTML =
|
||
alerts.expired.length + alerts.expiring.length
|
||
? `<p class="notice">${alerts.expired.length} expired, ${alerts.expiring.length} expiring within
|
||
${alerts.warningDays} days. Ask them for an updated card before their next visit.</p>`
|
||
: '';
|
||
|
||
$('#recurring-table').innerHTML = table(
|
||
['', 'Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
|
||
rows.map(
|
||
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
|
||
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
|
||
${p.email ? `<small>${esc(p.email)}</small>` : ''}</td>
|
||
<td class="mono">${esc(p.phone)}</td>
|
||
<td>${p.siteId ? esc(siteName(p.siteId)) : '<span class="pill off">Any site</span>'}</td>
|
||
<td>${p.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(p.checkType)} ${esc(p.checkNumber || '')}`}</td>
|
||
<td>${p.checkType === 'NONE' ? '—' : expiryCell(p.expiry, p.checkExpiry)}</td>
|
||
<td>${p.active ? '<span class="pill">Active</span>' : '<span class="pill off">Inactive</span>'}</td>
|
||
<td class="actions">
|
||
<button class="ghost" data-pass="${p.id}">Print card</button>
|
||
<button class="ghost" data-pin="${p.id}">New PIN</button>
|
||
<button class="ghost" data-edit-freq="${p.id}">Edit</button>
|
||
<button class="ghost danger" data-remove-freq="${p.id}">Remove</button>
|
||
</td>
|
||
</tr>`
|
||
),
|
||
'No recurring visitors yet. Add one so they can sign in with a PIN.'
|
||
);
|
||
|
||
$$('[data-pass]').forEach((btn) =>
|
||
btn.addEventListener('click', () => window.open(`/admin/api/pass/${btn.dataset.pass}`, '_blank'))
|
||
);
|
||
$$('[data-pin]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Issue a new PIN? The old one stops working straight away.')) return;
|
||
const { pin } = await api(`/frequent/${btn.dataset.pin}/pin`, { method: 'POST' });
|
||
showPin(pin, btn.dataset.pin);
|
||
})
|
||
);
|
||
$$('[data-edit-freq]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`));
|
||
})
|
||
);
|
||
$$('[data-remove-freq]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
try {
|
||
confirmRemoveFrequent(await api(`/frequent/${btn.dataset.removeFreq}`));
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
})
|
||
);
|
||
}
|
||
|
||
function hostOptions(selectedId) {
|
||
return (
|
||
'<option value="">No default</option>' +
|
||
hosts
|
||
.filter((h) => h.active)
|
||
.map(
|
||
(h) =>
|
||
`<option value="${h.id}" ${Number(selectedId) === h.id ? 'selected' : ''}>${esc(h.name)}</option>`
|
||
)
|
||
.join('')
|
||
);
|
||
}
|
||
|
||
function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
|
||
return (
|
||
`<option value="">${esc(anyLabel)}</option>` +
|
||
sites
|
||
.map(
|
||
(s) =>
|
||
`<option value="${s.id}" ${Number(selectedId) === s.id ? 'selected' : ''}>${esc(s.name)}</option>`
|
||
)
|
||
.join('')
|
||
);
|
||
}
|
||
|
||
function openModal(
|
||
title,
|
||
bodyHtml,
|
||
onSave,
|
||
{ saveLabel = 'Save', hideCancel = false, destructive = false, onOpen, onClose } = {}
|
||
) {
|
||
$('#modal-title').textContent = title;
|
||
$('#modal-body').innerHTML = bodyHtml;
|
||
$('#modal-save').textContent = saveLabel;
|
||
$('#modal-cancel').hidden = hideCancel;
|
||
const modal = $('#modal');
|
||
modal.classList.toggle('destructive', destructive);
|
||
modal.returnValue = '';
|
||
modal.showModal();
|
||
onOpen?.();
|
||
modal.onclose = () => {
|
||
$('#modal-cancel').hidden = false;
|
||
$('#modal-save').textContent = 'Save';
|
||
modal.classList.remove('destructive');
|
||
onClose?.();
|
||
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
||
};
|
||
}
|
||
|
||
/* --------------------------------------------------- visitor photo editor */
|
||
// A recurring visitor can have a photo kept on file, so the kiosk never asks them
|
||
// to pose again. It can come from this machine's camera or from a file.
|
||
|
||
const photoEditor = { dataUrl: null, remove: false, stream: null };
|
||
|
||
function photoEditorMarkup(person) {
|
||
const existing = person?.hasPhoto ? `/admin/api/frequent/${person.id}/photo?t=${Date.now()}` : null;
|
||
return `
|
||
<div class="photo-editor">
|
||
<div class="photo-frame">
|
||
<img id="photo-preview" alt="Photo on file"${existing ? ` src="${existing}"` : ' hidden'}>
|
||
<video id="photo-video" playsinline muted autoplay hidden></video>
|
||
<p class="photo-empty" id="photo-empty"${existing ? ' hidden' : ''}>No photo on file</p>
|
||
</div>
|
||
<div class="photo-actions">
|
||
<button type="button" class="ghost" id="photo-camera">Use the camera</button>
|
||
<button type="button" class="ghost" id="photo-shoot" hidden>Take it</button>
|
||
<label class="ghost photo-upload">Upload a file
|
||
<input type="file" id="photo-file" accept="image/*" hidden>
|
||
</label>
|
||
<button type="button" class="ghost danger" id="photo-clear"${existing ? '' : ' hidden'}>Remove</button>
|
||
</div>
|
||
<p class="hint">With a photo saved here, this visitor signs in with their PIN and their
|
||
pass prints straight away — the kiosk does not ask them to pose.</p>
|
||
</div>`;
|
||
}
|
||
|
||
function wirePhotoEditor() {
|
||
photoEditor.dataUrl = null;
|
||
photoEditor.remove = false;
|
||
|
||
const preview = $('#photo-preview');
|
||
const video = $('#photo-video');
|
||
const empty = $('#photo-empty');
|
||
|
||
const showImage = (src) => {
|
||
preview.src = src;
|
||
preview.hidden = false;
|
||
video.hidden = true;
|
||
empty.hidden = true;
|
||
$('#photo-shoot').hidden = true;
|
||
$('#photo-camera').textContent = 'Retake';
|
||
$('#photo-clear').hidden = false;
|
||
};
|
||
|
||
$('#photo-camera').addEventListener('click', async () => {
|
||
try {
|
||
photoEditor.stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: 'user', width: { ideal: 960 } },
|
||
audio: false,
|
||
});
|
||
video.srcObject = photoEditor.stream;
|
||
video.hidden = false;
|
||
preview.hidden = true;
|
||
empty.hidden = true;
|
||
$('#photo-shoot').hidden = false;
|
||
} catch {
|
||
toast('No camera available on this machine. Upload a file instead.', true);
|
||
}
|
||
});
|
||
|
||
$('#photo-shoot').addEventListener('click', () => {
|
||
// Square, cropped from the centre, to match the kiosk camera and the badge.
|
||
const side = Math.min(video.videoWidth, video.videoHeight);
|
||
if (!side) return toast('The camera is not ready yet. Try again in a moment.', true);
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 640;
|
||
canvas.height = 640;
|
||
canvas
|
||
.getContext('2d')
|
||
.drawImage(video, (video.videoWidth - side) / 2, (video.videoHeight - side) / 2, side, side, 0, 0, 640, 640);
|
||
photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72);
|
||
photoEditor.remove = false;
|
||
stopPhotoCamera();
|
||
showImage(photoEditor.dataUrl);
|
||
});
|
||
|
||
$('#photo-file').addEventListener('change', async (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
if (file.size > 4 * 1024 * 1024) return toast('That image is over 4 MB. Use a smaller one.', true);
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
photoEditor.dataUrl = reader.result;
|
||
photoEditor.remove = false;
|
||
stopPhotoCamera();
|
||
showImage(reader.result);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
$('#photo-clear').addEventListener('click', () => {
|
||
photoEditor.dataUrl = null;
|
||
photoEditor.remove = true;
|
||
stopPhotoCamera();
|
||
preview.hidden = true;
|
||
video.hidden = true;
|
||
empty.hidden = false;
|
||
$('#photo-shoot').hidden = true;
|
||
$('#photo-clear').hidden = true;
|
||
$('#photo-camera').textContent = 'Use the camera';
|
||
});
|
||
}
|
||
|
||
function stopPhotoCamera() {
|
||
if (!photoEditor.stream) return;
|
||
photoEditor.stream.getTracks().forEach((t) => t.stop());
|
||
photoEditor.stream = null;
|
||
const video = $('#photo-video');
|
||
if (video) video.srcObject = null;
|
||
}
|
||
|
||
function openRecurringModal(person = null) {
|
||
const editing = Boolean(person);
|
||
openModal(
|
||
editing ? `Edit ${person.firstName} ${person.lastName}` : 'New recurring visitor',
|
||
`
|
||
${field('First name', 'firstName', person?.firstName)}
|
||
${field('Last name', 'lastName', person?.lastName)}
|
||
${field('Mobile number (their username)', 'phone', person?.phone, 'tel')}
|
||
${field('Email address', 'email', person?.email, 'email')}
|
||
<label class="modal-field"><span>Check held</span>
|
||
<select name="checkType">
|
||
<option value="NONE" ${!person || person.checkType === 'NONE' ? 'selected' : ''}>None</option>
|
||
<option value="WWCC" ${person?.checkType === 'WWCC' ? 'selected' : ''}>Working with Children Check</option>
|
||
<option value="VIT" ${person?.checkType === 'VIT' ? 'selected' : ''}>Victorian Institute of Teaching</option>
|
||
</select></label>
|
||
${field('Check number', 'checkNumber', person?.checkNumber)}
|
||
${field('Check expires', 'checkExpiry', person?.checkExpiry, 'date')}
|
||
<label class="modal-field"><span>Site</span>
|
||
<select name="siteId">${siteOptions(person?.siteId)}</select></label>
|
||
<label class="modal-field"><span>Usually visiting</span>
|
||
<select name="defaultHostId">${hostOptions(person?.defaultHostId)}</select></label>
|
||
${field('PIN (leave blank to generate one)', 'pin', '')}
|
||
${field('Notes', 'notes', person?.notes)}
|
||
<h4 class="modal-section">Photo on file</h4>
|
||
${photoEditorMarkup(person)}
|
||
${editing ? `<label class="inline"><input type="checkbox" name="active" ${person.active ? 'checked' : ''}> Active</label>` : ''}
|
||
`,
|
||
async (form) => {
|
||
const payload = Object.fromEntries(form.entries());
|
||
payload.active = editing ? form.has('active') : true;
|
||
if (!payload.pin) delete payload.pin;
|
||
if (photoEditor.dataUrl) payload.photo = photoEditor.dataUrl;
|
||
if (photoEditor.remove) payload.removePhoto = true;
|
||
try {
|
||
const saved = editing
|
||
? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload })
|
||
: await api('/frequent', { method: 'POST', body: payload });
|
||
loadRecurring();
|
||
if (editing) toast('Saved.');
|
||
else showPin(saved.pin, saved.id);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
},
|
||
{ onOpen: wirePhotoEditor, onClose: stopPhotoCamera }
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Removing a saved visitor is permanent, so the confirmation spells out what
|
||
* happens: the record and its PIN go, the visit history stays. Deactivating is
|
||
* offered alongside for the common case of someone who has simply stopped coming.
|
||
*/
|
||
function confirmRemoveFrequent(person) {
|
||
const name = `${person.firstName} ${person.lastName}`;
|
||
openModal(
|
||
`Remove ${name}?`,
|
||
`${
|
||
person.onSite
|
||
? `<p class="notice">${esc(person.firstName)} is signed in right now. Removing the
|
||
record will not sign them out — their visit stays open and they can still sign out
|
||
with their last name and mobile number.</p>`
|
||
: ''
|
||
}
|
||
<ul class="plain-list">
|
||
<li>Their saved record, PIN and photo are deleted for good.</li>
|
||
<li>Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.</li>
|
||
<li>${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.</li>
|
||
<li>Any card they are carrying stops working.</li>
|
||
</ul>
|
||
<p class="hint">If they might come back, untick <strong>Active</strong> in Edit instead —
|
||
that keeps the record and their history intact.</p>`,
|
||
async () => {
|
||
try {
|
||
const result = await api(`/frequent/${person.id}?force=1`, { method: 'DELETE' });
|
||
toast(`${result.name} removed. ${result.visitsKept} past visit(s) kept in the log.`);
|
||
loadRecurring();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
},
|
||
{ saveLabel: 'Remove permanently', destructive: true }
|
||
);
|
||
}
|
||
|
||
function showPin(pin, id) {
|
||
openModal(
|
||
'PIN issued',
|
||
`<p class="pin-reveal">${esc(pin)}</p>
|
||
<p class="hint">Print the card now, or write this down. You can reprint it later from the
|
||
recurring visitors list.</p>`,
|
||
() => window.open(`/admin/api/pass/${id}`, '_blank'),
|
||
{ saveLabel: 'Print card' }
|
||
);
|
||
}
|
||
|
||
$('#new-recurring').addEventListener('click', () => openRecurringModal());
|
||
|
||
/* --------------------------------------------------------------- hosts */
|
||
|
||
async function loadHosts() {
|
||
hosts = await api(scoped('/hosts'));
|
||
$('#hosts-scope').textContent =
|
||
activeSiteId === 'all'
|
||
? 'Showing every site. Pick a single site above before adding or importing people.'
|
||
: `Showing ${siteName(Number(activeSiteId))}.`;
|
||
|
||
const showSite = activeSiteId === 'all' && sites.length > 1;
|
||
$('#hosts-table').innerHTML = table(
|
||
[...(showSite ? ['Site'] : []), 'Name', 'Area', 'Email', 'Status', ''],
|
||
hosts.map(
|
||
(h) => `<tr>
|
||
${showSite ? `<td>${esc(siteName(h.site_id))}</td>` : ''}
|
||
<td><strong>${esc(h.name)}</strong></td>
|
||
<td>${esc(h.area || '—')}</td>
|
||
<td>${esc(h.email || '—')}</td>
|
||
<td>${h.active ? '<span class="pill">Shown</span>' : '<span class="pill off">Hidden</span>'}</td>
|
||
<td class="actions">
|
||
<button class="ghost" data-edit-host="${h.id}">Edit</button>
|
||
<button class="ghost" data-toggle-host="${h.id}">${h.active ? 'Hide' : 'Show'}</button>
|
||
</td>
|
||
</tr>`
|
||
),
|
||
'No one is listed yet. Add people, or import a CSV, so visitors can say who they are seeing.'
|
||
);
|
||
|
||
$$('[data-edit-host]').forEach((btn) =>
|
||
btn.addEventListener('click', () =>
|
||
openHostModal(hosts.find((h) => h.id === Number(btn.dataset.editHost)))
|
||
)
|
||
);
|
||
$$('[data-toggle-host]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
const host = hosts.find((h) => h.id === Number(btn.dataset.toggleHost));
|
||
await api(`/hosts/${host.id}`, { method: 'PATCH', body: { active: !host.active } });
|
||
loadHosts();
|
||
})
|
||
);
|
||
}
|
||
|
||
function openHostModal(host = null) {
|
||
if (!host && activeSiteId === 'all' && sites.length > 1) {
|
||
return toast('Choose a single site above first, so the person lands in the right list.', true);
|
||
}
|
||
openModal(
|
||
host ? `Edit ${host.name}` : `Add a person to ${siteName(Number(activeSiteId))}`,
|
||
`${field('Name', 'name', host?.name)}
|
||
${field('Area, team or role', 'area', host?.area)}
|
||
${field('Email address', 'email', host?.email, 'email')}`,
|
||
async (form) => {
|
||
const payload = Object.fromEntries(form.entries());
|
||
try {
|
||
if (host) await api(`/hosts/${host.id}`, { method: 'PATCH', body: payload });
|
||
else await api('/hosts', { method: 'POST', body: { ...payload, siteId: activeSiteId } });
|
||
loadHosts();
|
||
toast('Saved.');
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
$('#new-host').addEventListener('click', () => openHostModal());
|
||
|
||
$('#host-file').addEventListener('change', async (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) $('#host-csv').value = await file.text();
|
||
});
|
||
|
||
$('#do-host-import').addEventListener('click', async () => {
|
||
const csv = $('#host-csv').value.trim();
|
||
if (!csv) return toast('Paste a CSV or choose a file first.', true);
|
||
if (activeSiteId === 'all' && sites.length > 1) {
|
||
return toast('Choose a single site above before importing.', true);
|
||
}
|
||
try {
|
||
const result = await api('/hosts/import', {
|
||
method: 'POST',
|
||
body: { csv, replace: $('#host-replace').checked, siteId: activeSiteId },
|
||
});
|
||
toast(`${result.added} added, ${result.updated} updated. ${result.total} people listed.`);
|
||
$('#host-csv').value = '';
|
||
loadHosts();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
|
||
/* --------------------------------------------------------------- sites */
|
||
|
||
async function loadSites() {
|
||
sites = await api('/sites');
|
||
renderSiteFilter();
|
||
|
||
$('#sites-list').innerHTML = sites
|
||
.map(
|
||
(s) => `<article class="card site-card">
|
||
<div class="site-head">
|
||
<div>
|
||
<h3>${esc(s.name)} ${s.active ? '' : '<span class="pill off">Inactive</span>'}</h3>
|
||
<p class="hint">Kiosk address: <code>/?site=${esc(s.slug)}</code></p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="ghost" data-edit-site="${s.id}">Edit</button>
|
||
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
||
</div>
|
||
</div>
|
||
<dl class="site-meta">
|
||
<dt>Badge printing</dt>
|
||
<dd>${
|
||
s.badge.enabled
|
||
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}${s.badge.accent ? ', two-colour' : ''}`
|
||
: 'Off'
|
||
}</dd>
|
||
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
|
||
<dt>Kiosk branding</dt>
|
||
<dd>
|
||
${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} ·
|
||
<span class="swatch" style="background:${esc(s.branding.theme.brand)}"></span>
|
||
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
|
||
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
|
||
</dd>
|
||
</dl>
|
||
</article>`
|
||
)
|
||
.join('');
|
||
|
||
$$('[data-edit-site]').forEach((btn) =>
|
||
btn.addEventListener('click', () =>
|
||
openSiteModal(sites.find((s) => s.id === Number(btn.dataset.editSite)))
|
||
)
|
||
);
|
||
$$('[data-preview-badge]').forEach((btn) =>
|
||
btn.addEventListener('click', () =>
|
||
window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank')
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes
|
||
* 12–62 mm wide media and prints up to 60.96 mm across, so anything wider than
|
||
* 62 mm is for a different printer.
|
||
*/
|
||
const LABEL_PRESETS = [
|
||
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
|
||
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
|
||
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
|
||
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
|
||
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
|
||
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
|
||
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
|
||
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
|
||
];
|
||
|
||
function openSiteModal(site) {
|
||
openModal(
|
||
`Edit ${site.name}`,
|
||
`${field('Site name', 'name', site.name)}
|
||
${field('Kiosk slug', 'slug', site.slug)}
|
||
<label class="inline"><input type="checkbox" name="active" ${site.active ? 'checked' : ''}> Active</label>
|
||
<h4 class="modal-section">Badge printing</h4>
|
||
<label class="inline"><input type="checkbox" name="badgeEnabled" ${site.badge.enabled ? 'checked' : ''}> Print a badge after each sign in</label>
|
||
<label class="modal-field"><span>Label stock</span>
|
||
<select name="preset" id="badge-preset">
|
||
<option value="">Custom size</option>
|
||
${LABEL_PRESETS.map(
|
||
(p) =>
|
||
`<option value="${p.id}" ${
|
||
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
|
||
}>${esc(p.label)}</option>`
|
||
).join('')}
|
||
</select></label>
|
||
<div class="modal-row">
|
||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||
</div>
|
||
<p class="hint" id="badge-warning" hidden></p>
|
||
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
|
||
<p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
|
||
roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
|
||
${field('Line printed at the bottom', 'note', site.badge.note)}
|
||
<h4 class="modal-section">Kiosk branding</h4>
|
||
<div class="banner-editor">
|
||
<div class="banner-frame" id="banner-frame">
|
||
<img id="banner-preview" alt="Banner"${
|
||
site.branding.hasBanner ? ` src="/admin/api/sites/${site.id}/banner?t=${Date.now()}"` : ' hidden'
|
||
}>
|
||
<p class="banner-empty" id="banner-empty"${site.branding.hasBanner ? ' hidden' : ''}>No banner</p>
|
||
</div>
|
||
<div class="photo-actions">
|
||
<label class="ghost photo-upload">Upload an image
|
||
<input type="file" id="banner-file" accept="image/png,image/jpeg,image/webp" hidden>
|
||
</label>
|
||
<button type="button" class="ghost danger" id="banner-clear"${site.branding.hasBanner ? '' : ' hidden'}>Remove</button>
|
||
</div>
|
||
<p class="hint">A PNG with a transparent background works best — it sits straight on the
|
||
bar colour with nothing painted behind it. Up to 2 MB.</p>
|
||
<div class="modal-row">
|
||
${field('Height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
|
||
<label class="modal-field"><span>Position in the bar</span>
|
||
<select name="bannerAlign">
|
||
<option value="left" ${site.branding.bannerAlign !== 'center' ? 'selected' : ''}>Left</option>
|
||
<option value="center" ${site.branding.bannerAlign === 'center' ? 'selected' : ''}>Centred</option>
|
||
</select></label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-row modal-row-3">
|
||
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
|
||
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
|
||
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
|
||
</div>
|
||
<p class="hint">Text colours are worked out from these, so a pale brand colour gets dark
|
||
text rather than white. Clear a box to go back to the default.</p>`,
|
||
async (form) => {
|
||
const data = Object.fromEntries(form.entries());
|
||
try {
|
||
await api(`/sites/${site.id}`, {
|
||
method: 'PATCH',
|
||
body: {
|
||
name: data.name,
|
||
slug: data.slug,
|
||
active: form.has('active'),
|
||
badge: {
|
||
enabled: form.has('badgeEnabled'),
|
||
widthMm: Number(data.widthMm),
|
||
heightMm: Number(data.heightMm),
|
||
showPhoto: form.has('showPhoto'),
|
||
accent: form.has('accent'),
|
||
note: data.note,
|
||
},
|
||
branding: {
|
||
brand: data.brand || null,
|
||
signout: data.signout || null,
|
||
page: data.page || null,
|
||
bannerHeight: Number(data.bannerHeight) || 64,
|
||
bannerAlign: data.bannerAlign,
|
||
},
|
||
},
|
||
});
|
||
if (bannerEditor.dataUrl) {
|
||
await api(`/sites/${site.id}/banner`, {
|
||
method: 'POST',
|
||
body: { image: bannerEditor.dataUrl },
|
||
});
|
||
} else if (bannerEditor.remove) {
|
||
await api(`/sites/${site.id}/banner`, { method: 'DELETE' });
|
||
}
|
||
toast('Site saved. Reload the kiosk to see the change.');
|
||
loadSites();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
},
|
||
{
|
||
onOpen: () => {
|
||
wireBadgePreset();
|
||
wireBannerEditor();
|
||
},
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A colour box paired with a text field, so a colour can be picked by eye or
|
||
* pasted from a brand guide, and cleared entirely to fall back to the default.
|
||
*/
|
||
function colourField(label, name, value, fallback) {
|
||
const current = value || '';
|
||
return `<label class="modal-field colour-field">
|
||
<span>${esc(label)}</span>
|
||
<span class="colour-row">
|
||
<input type="color" data-colour-for="${name}" value="${esc(current || fallback)}">
|
||
<input type="text" name="${name}" value="${esc(current)}" placeholder="${esc(fallback)}"
|
||
maxlength="7" spellcheck="false">
|
||
</span>
|
||
</label>`;
|
||
}
|
||
|
||
const bannerEditor = { dataUrl: null, remove: false };
|
||
|
||
function wireBannerEditor() {
|
||
bannerEditor.dataUrl = null;
|
||
bannerEditor.remove = false;
|
||
|
||
// Keep the swatch and the hex box in step, in both directions.
|
||
$$('#modal-form [data-colour-for]').forEach((swatch) => {
|
||
const text = $(`#modal-form [name="${swatch.dataset.colourFor}"]`);
|
||
swatch.addEventListener('input', () => {
|
||
text.value = swatch.value;
|
||
});
|
||
text.addEventListener('input', () => {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(text.value.trim())) swatch.value = text.value.trim();
|
||
});
|
||
});
|
||
|
||
$('#banner-file').addEventListener('change', (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
if (file.size > 2 * 1024 * 1024) return toast('That image is over 2 MB. Use a smaller one.', true);
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
bannerEditor.dataUrl = reader.result;
|
||
bannerEditor.remove = false;
|
||
$('#banner-preview').src = reader.result;
|
||
$('#banner-preview').hidden = false;
|
||
$('#banner-empty').hidden = true;
|
||
$('#banner-clear').hidden = false;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
$('#banner-clear').addEventListener('click', () => {
|
||
bannerEditor.dataUrl = null;
|
||
bannerEditor.remove = true;
|
||
$('#banner-preview').hidden = true;
|
||
$('#banner-preview').removeAttribute('src');
|
||
$('#banner-empty').hidden = false;
|
||
$('#banner-clear').hidden = true;
|
||
});
|
||
}
|
||
|
||
function wireBadgePreset() {
|
||
const width = $('#modal-form [name="widthMm"]');
|
||
const height = $('#modal-form [name="heightMm"]');
|
||
const warning = $('#badge-warning');
|
||
|
||
const check = () => {
|
||
const w = Number(width.value);
|
||
const h = Number(height.value);
|
||
if (w > 62) {
|
||
warning.hidden = false;
|
||
warning.className = 'hint warn';
|
||
warning.textContent = `${w} mm is wider than a QL-820NWB can take — it handles 12 to 62 mm media, printing up to 60.96 mm across. Fine for a different printer.`;
|
||
} else if (h < w * 1.2 && $('#badge-photo').checked && w <= 40) {
|
||
warning.hidden = false;
|
||
warning.className = 'hint warn';
|
||
warning.textContent = 'A photo on a label this narrow leaves very little room for the name. Consider turning the photo off.';
|
||
} else {
|
||
warning.hidden = true;
|
||
}
|
||
};
|
||
|
||
$('#badge-preset').addEventListener('change', (event) => {
|
||
const preset = LABEL_PRESETS.find((p) => p.id === event.target.value);
|
||
if (!preset) return;
|
||
width.value = preset.w;
|
||
height.value = preset.h;
|
||
$('#badge-photo').checked = preset.photo;
|
||
$('#badge-accent').checked = Boolean(preset.accent);
|
||
check();
|
||
});
|
||
|
||
[width, height].forEach((el) => el.addEventListener('input', () => {
|
||
$('#badge-preset').value = '';
|
||
check();
|
||
}));
|
||
$('#badge-photo').addEventListener('change', check);
|
||
check();
|
||
}
|
||
|
||
$('#new-site').addEventListener('click', () => {
|
||
openModal('Add a site', field('Site name', 'name', ''), async (form) => {
|
||
try {
|
||
await api('/sites', { method: 'POST', body: { name: form.get('name') } });
|
||
toast('Site added. Set its badge options next.');
|
||
loadSites();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
});
|
||
|
||
function renderSiteFilter() {
|
||
const select = $('#site-filter');
|
||
const scopedToOne = Boolean(me?.siteId);
|
||
$('#site-switch').hidden = sites.length < 2 && !scopedToOne;
|
||
select.innerHTML =
|
||
(scopedToOne ? '' : '<option value="all">All sites</option>') +
|
||
sites.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
|
||
if (scopedToOne) activeSiteId = String(me.siteId);
|
||
select.value = activeSiteId;
|
||
$('#csv-link').href = scoped('/admin/api/visits.csv');
|
||
}
|
||
|
||
/* -------------------------------------------------------------- admins */
|
||
|
||
async function loadAdmins() {
|
||
if (me.role !== 'owner') {
|
||
$('#admins-table').innerHTML = '<p class="empty">Only an owner account can manage admins.</p>';
|
||
return;
|
||
}
|
||
const rows = await api('/users');
|
||
$('#admins-note').textContent = me.domainRule
|
||
? `New accounts must use an ${me.domainRule} address.`
|
||
: 'Any email address can be used for an admin account.';
|
||
|
||
$('#admins-table').innerHTML = table(
|
||
['Email', 'Name', 'Role', 'Site', 'Two factor', 'Last sign in', ''],
|
||
rows.map(
|
||
(u) => `<tr>
|
||
<td><strong>${esc(u.email)}</strong>${u.active ? '' : ' <span class="pill off">Disabled</span>'}</td>
|
||
<td>${esc(u.name || '—')}</td>
|
||
<td>${esc(u.role)}</td>
|
||
<td>${u.siteId ? esc(siteName(u.siteId)) : 'All sites'}</td>
|
||
<td>${u.twoFactorOn ? '<span class="pill">On</span>' : '<span class="pill warn">Not set up</span>'}</td>
|
||
<td class="mono">${stamp(u.lastLoginAt)}</td>
|
||
<td class="actions">
|
||
<button class="ghost" data-edit-user="${u.id}">Edit</button>
|
||
<button class="ghost" data-reset-pw="${u.id}">Reset password</button>
|
||
<button class="ghost" data-reset-2fa="${u.id}">Reset 2FA</button>
|
||
</td>
|
||
</tr>`
|
||
),
|
||
'No admin accounts.'
|
||
);
|
||
|
||
$$('[data-edit-user]').forEach((btn) =>
|
||
btn.addEventListener('click', () => {
|
||
const user = rows.find((u) => u.id === Number(btn.dataset.editUser));
|
||
openModal(
|
||
`Edit ${user.email}`,
|
||
`${field('Name', 'name', user.name)}
|
||
<label class="modal-field"><span>Role</span>
|
||
<select name="role">
|
||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin — day to day</option>
|
||
<option value="owner" ${user.role === 'owner' ? 'selected' : ''}>Owner — can manage admins and sites</option>
|
||
</select></label>
|
||
<label class="modal-field"><span>Limit to one site</span>
|
||
<select name="siteId">${siteOptions(user.siteId, { anyLabel: 'All sites' })}</select></label>
|
||
<label class="inline"><input type="checkbox" name="active" ${user.active ? 'checked' : ''}> Active</label>`,
|
||
async (form) => {
|
||
try {
|
||
await api(`/users/${user.id}`, {
|
||
method: 'PATCH',
|
||
body: {
|
||
name: form.get('name'),
|
||
role: form.get('role'),
|
||
siteId: form.get('siteId') || null,
|
||
active: form.has('active'),
|
||
},
|
||
});
|
||
toast('Saved.');
|
||
loadAdmins();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
);
|
||
})
|
||
);
|
||
|
||
$$('[data-reset-pw]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Reset this password? They will have to set a new one at next sign in.')) return;
|
||
const { temporaryPassword } = await api(`/users/${btn.dataset.resetPw}/reset-password`, {
|
||
method: 'POST',
|
||
});
|
||
openModal(
|
||
'Temporary password',
|
||
`<p class="pin-reveal small">${esc(temporaryPassword)}</p>
|
||
<p class="hint">Give this to them in person or over the phone. They will be asked to
|
||
change it as soon as they sign in.</p>`,
|
||
null,
|
||
{ saveLabel: 'Done', hideCancel: true }
|
||
);
|
||
})
|
||
);
|
||
|
||
$$('[data-reset-2fa]').forEach((btn) =>
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Clear their two factor setup? They will enrol again at next sign in.')) return;
|
||
await api(`/users/${btn.dataset.reset2fa}/reset-2fa`, { method: 'POST' });
|
||
toast('Two factor cleared.');
|
||
loadAdmins();
|
||
})
|
||
);
|
||
}
|
||
|
||
$('#new-admin').addEventListener('click', () => {
|
||
openModal(
|
||
'Invite an admin',
|
||
`${field('Email address', 'email', '', 'email')}
|
||
${field('Name', 'name', '')}
|
||
<label class="modal-field"><span>Role</span>
|
||
<select name="role">
|
||
<option value="admin">Admin — day to day</option>
|
||
<option value="owner">Owner — can manage admins and sites</option>
|
||
</select></label>
|
||
<label class="modal-field"><span>Limit to one site</span>
|
||
<select name="siteId">${siteOptions(null, { anyLabel: 'All sites' })}</select></label>`,
|
||
async (form) => {
|
||
try {
|
||
const created = await api('/users', {
|
||
method: 'POST',
|
||
body: {
|
||
email: form.get('email'),
|
||
name: form.get('name'),
|
||
role: form.get('role'),
|
||
siteId: form.get('siteId') || null,
|
||
},
|
||
});
|
||
loadAdmins();
|
||
openModal(
|
||
'Account created',
|
||
`<p class="hint">Temporary password for ${esc(created.email)}:</p>
|
||
<p class="pin-reveal small">${esc(created.temporaryPassword)}</p>
|
||
<p class="hint">They will set their own password and enrol two factor at first sign in.</p>`,
|
||
null,
|
||
{ saveLabel: 'Done', hideCancel: true }
|
||
);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
/* -------------------------------------------------------------- system */
|
||
|
||
async function loadSystem() {
|
||
const s = await api(scoped('/status'));
|
||
$('#system-body').innerHTML = `
|
||
<dl>
|
||
<dt>Time zone</dt><dd>${esc(s.timezone)}</dd>
|
||
<dt>Sites</dt><dd>${s.siteCount}</dd>
|
||
<dt>Photo required</dt><dd>${s.requirePhoto ? 'Yes' : 'No'}</dd>
|
||
<dt>Photos kept for</dt><dd>${s.photoRetentionDays} days</dd>
|
||
<dt>Expiry warning</dt><dd>${s.expiryWarningDays} days before a WWCC or VIT lapses</dd>
|
||
<dt>Nightly auto sign out</dt><dd>${s.autoSignOutTime ? esc(s.autoSignOutTime) : 'Off'}</dd>
|
||
<dt>Two factor</dt><dd>${s.require2fa ? 'Required for every admin' : 'Optional'}</dd>
|
||
<dt>Admin email domain</dt><dd>${s.domainRule ? esc(s.domainRule) : 'Any address'}</dd>
|
||
<dt>On site now</dt><dd>${s.onSite}</dd>
|
||
<dt>Google Sheet</dt><dd>${
|
||
s.sheets.enabled
|
||
? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}`
|
||
: 'Turned off in the environment file.'
|
||
}</dd>
|
||
<dt>History tab</dt><dd>${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}</dd>
|
||
<dt>Live tab</dt><dd>${esc(s.sheets.onSiteTab)} — ${
|
||
s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site`
|
||
}, last synced ${stamp(s.sheets.lastOnSiteSync)}${
|
||
s.sheets.onSiteError ? ` <span class="pill bad">${esc(s.sheets.onSiteError)}</span>` : ''
|
||
}</dd>
|
||
</dl>
|
||
<div class="sys-actions">
|
||
<button class="ghost" id="sheet-test">Test the sheet connection</button>
|
||
<button class="ghost" id="sheet-flush">Send queued rows now</button>
|
||
<button class="ghost" id="sheet-resync">Rebuild the live list</button>
|
||
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
|
||
</div>
|
||
<h3 class="section-gap">Certificate</h3>
|
||
${renderTls(s.tls)}`;
|
||
|
||
$('#sheet-test').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/sheets/test', { method: 'POST' });
|
||
toast(`Connected to "${r.title}".`);
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
$('#sheet-resync').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/sheets/sync', { method: 'POST' });
|
||
toast(r.skipped ? 'Sheet mirroring is off.' : `Live tab rewritten with ${r.rows} on site.`);
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
$('#sheet-flush').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/sheets/flush', { method: 'POST' });
|
||
toast(`${r.sent} sent, ${r.remaining} still queued.`);
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
$('#sheet-resync').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/sheets/resync', { method: 'POST' });
|
||
toast(`Live list rebuilt with ${r.rows} ${r.rows === 1 ? 'person' : 'people'}.`);
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
|
||
$('#renew-cert')?.addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/tls/renew', { method: 'POST', body: { newCa: false } });
|
||
toast(
|
||
r.info.server
|
||
? `Certificate good until ${new Date(r.info.server.validTo).toLocaleDateString('en-AU')}.`
|
||
: 'Certificate checked.'
|
||
);
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
|
||
$('#new-ca')?.addEventListener('click', async () => {
|
||
const warning =
|
||
'Create a brand new certificate authority?' +
|
||
'\n\n' +
|
||
'Every kiosk device will show a warning until you install the new CA file on it. ' +
|
||
'Only do this if the old key may have leaked.';
|
||
if (!confirm(warning)) return;
|
||
try {
|
||
await api('/tls/renew', { method: 'POST', body: { newCa: true } });
|
||
toast('New authority created. Install it on every kiosk device.');
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
|
||
$('#photo-purge').addEventListener('click', async () => {
|
||
if (!confirm('Delete photos older than the retention window? This cannot be undone.')) return;
|
||
const r = await api('/photos/purge', { method: 'POST' });
|
||
toast(`${r.purged} photo(s) deleted.`);
|
||
});
|
||
|
||
renderAccount(s);
|
||
$$('.owner-only').forEach((el) => {
|
||
el.hidden = me.role !== 'owner';
|
||
});
|
||
}
|
||
|
||
function renderTls(tls) {
|
||
if (!tls?.enabled) {
|
||
return `<p class="notice">HTTPS is off, so the kiosk camera will only work on localhost.
|
||
Set <code>HTTPS_ENABLED=true</code> in the environment file and restart.</p>`;
|
||
}
|
||
if (!tls.server) {
|
||
return '<p class="notice">HTTPS is on but no certificate could be read.</p>';
|
||
}
|
||
const soon = tls.server.daysLeft < 30;
|
||
return `
|
||
<dl>
|
||
<dt>Server certificate</dt>
|
||
<dd>Valid until ${new Date(tls.server.validTo).toLocaleDateString('en-AU')}
|
||
<span class="pill ${soon ? 'warn' : ''}">${tls.server.daysLeft} days</span></dd>
|
||
<dt>Valid for</dt><dd>${esc(tls.server.names.join(', '))}</dd>
|
||
<dt>Authority expires</dt>
|
||
<dd>${tls.ca ? new Date(tls.ca.validTo).toLocaleDateString('en-AU') : '—'}
|
||
${tls.ca ? `<span class="pill">${tls.ca.daysLeft} days</span>` : ''}</dd>
|
||
<dt>CA fingerprint</dt><dd class="fingerprint">${esc(tls.ca?.fingerprint || '—')}</dd>
|
||
</dl>
|
||
<p class="hint">Install the CA file on each kiosk device once. The server certificate renews
|
||
itself before it lapses, and devices that trust the authority keep working without being
|
||
touched again.</p>
|
||
<div class="sys-actions">
|
||
<a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
|
||
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
|
||
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAccount(status) {
|
||
$('#account-body').innerHTML = `
|
||
<dl>
|
||
<dt>Signed in as</dt><dd>${esc(me.email)}</dd>
|
||
<dt>Role</dt><dd>${esc(me.role)}${me.siteId ? ` — ${esc(siteName(me.siteId))} only` : ''}</dd>
|
||
<dt>Two factor</dt><dd>${me.twoFactorOn ? 'On' : 'Not set up'}</dd>
|
||
</dl>
|
||
<div class="sys-actions">
|
||
<button class="ghost" id="change-password">Change my password</button>
|
||
${me.twoFactorOn
|
||
? status.require2fa
|
||
? ''
|
||
: '<button class="ghost danger" id="disable-2fa">Turn off two factor</button>'
|
||
: '<button class="ghost" id="enable-2fa">Set up two factor</button>'}
|
||
</div>`;
|
||
|
||
$('#change-password').addEventListener('click', () => {
|
||
openModal(
|
||
'Change your password',
|
||
`${field('Current password', 'currentPassword', '', 'password')}
|
||
${field('New password', 'newPassword', '', 'password')}
|
||
<p class="hint">At least 12 characters, with upper and lower case and a number.</p>`,
|
||
async (form) => {
|
||
try {
|
||
await api('/account/password', {
|
||
method: 'POST',
|
||
body: {
|
||
currentPassword: form.get('currentPassword'),
|
||
newPassword: form.get('newPassword'),
|
||
},
|
||
});
|
||
toast('Password changed.');
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
$('#enable-2fa')?.addEventListener('click', async () => {
|
||
const { qr, secret } = await api('/account/2fa/start', { method: 'POST' });
|
||
openModal(
|
||
'Set up two factor',
|
||
`<p class="hint">Scan this with your authenticator app, then enter the code it shows.</p>
|
||
<img src="${qr}" alt="Two factor QR code" width="200" height="200">
|
||
<p class="hint">Or enter this key by hand: <code>${esc(secret)}</code></p>
|
||
${field('6 digit code', 'code', '')}`,
|
||
async (form) => {
|
||
try {
|
||
const r = await api('/account/2fa/enable', { method: 'POST', body: { code: form.get('code') } });
|
||
me.twoFactorOn = true;
|
||
openModal(
|
||
'Recovery codes',
|
||
`<p class="hint">Each of these works once if you lose your phone. Save them somewhere safe.</p>
|
||
<ul class="recovery">${r.recoveryCodes.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`,
|
||
null,
|
||
{ saveLabel: 'Done', hideCancel: true }
|
||
);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
},
|
||
{ saveLabel: 'Turn on' }
|
||
);
|
||
});
|
||
|
||
$('#disable-2fa')?.addEventListener('click', () => {
|
||
openModal(
|
||
'Turn off two factor',
|
||
`<p class="hint">Confirm with your password.</p>
|
||
${field('Password', 'password', '', 'password')}`,
|
||
async (form) => {
|
||
try {
|
||
await api('/account/2fa/disable', { method: 'POST', body: { password: form.get('password') } });
|
||
me.twoFactorOn = false;
|
||
toast('Two factor turned off.');
|
||
loadSystem();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
},
|
||
{ saveLabel: 'Turn off' }
|
||
);
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- boot */
|
||
|
||
async function boot() {
|
||
const session = await api('/session');
|
||
|
||
// The server redirects an unauthenticated /admin to the login page, so reaching
|
||
// here without a session means it lapsed between the page load and this call.
|
||
if (!session.admin || session.mustChangePassword) return toLogin();
|
||
|
||
me = { ...session.user, domainRule: session.domainRule };
|
||
$('#site-name').textContent = session.siteName;
|
||
document.title = `Admin — ${session.siteName}`;
|
||
$$('.owner-only').forEach((el) => {
|
||
el.hidden = me.role !== 'owner';
|
||
});
|
||
|
||
sites = await api('/sites');
|
||
renderSiteFilter();
|
||
hosts = await api(scoped('/hosts'));
|
||
await loadAlerts().catch(() => {});
|
||
loadOnsite();
|
||
}
|
||
|
||
boot();
|