Public Access
1235 lines
44 KiB
JavaScript
1235 lines
44 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) {
|
||
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>`;
|
||
}
|
||
|
||
function loginError(message) {
|
||
const el = $('#login-error');
|
||
el.textContent = message;
|
||
el.hidden = !message;
|
||
}
|
||
|
||
/* --------------------------------------------------------- auth screens */
|
||
// The sign in flow is a sequence of small pages rather than one form that grows
|
||
// as it goes. Each screen carries its own title and instruction, the header shows
|
||
// where you are in the sequence, and the card animates between the two heights so
|
||
// a tall screen like the QR code reads as a new page, not a form unfolding.
|
||
|
||
const AUTH_SCREENS = {
|
||
'step-password': {
|
||
title: 'Sign in',
|
||
subtitle: 'Use the email address your account was set up with.',
|
||
focus: '#login-email',
|
||
},
|
||
'step-2fa-verify': {
|
||
screen: 'step-2fa',
|
||
title: 'Two factor',
|
||
subtitle: 'Enter the current code from your authenticator app.',
|
||
focus: '#twofa-code',
|
||
back: 'restart',
|
||
},
|
||
'step-2fa-setup': {
|
||
screen: 'step-2fa',
|
||
title: 'Set up two factor',
|
||
subtitle: 'Scan this with Google Authenticator, Authy, 1Password or similar, then enter the code it shows.',
|
||
focus: '#twofa-code',
|
||
back: 'restart',
|
||
},
|
||
'step-recovery': {
|
||
title: 'Recovery codes',
|
||
subtitle: 'Each of these works once, if you ever lose the phone with your authenticator on it. Save them somewhere safe now — they are not shown again.',
|
||
},
|
||
'step-newpassword': {
|
||
title: 'Choose a password',
|
||
subtitle: 'Set one only you know before you continue.',
|
||
focus: '#pw-current',
|
||
},
|
||
'step-setup': {
|
||
title: 'Not set up yet',
|
||
subtitle: 'No admin account exists on this server.',
|
||
},
|
||
};
|
||
|
||
// Which screens this particular sign in will pass through, so the header can say
|
||
// "2 of 3" honestly rather than guessing.
|
||
let authFlow = ['step-password'];
|
||
let authCurrent = 'step-password';
|
||
|
||
function setAuthFlow(steps) {
|
||
authFlow = steps;
|
||
}
|
||
|
||
function renderAuthRail(key) {
|
||
const rail = $('#auth-rail');
|
||
const index = authFlow.indexOf(key);
|
||
if (authFlow.length < 2 || index < 0) {
|
||
rail.hidden = true;
|
||
return;
|
||
}
|
||
rail.hidden = false;
|
||
rail.innerHTML =
|
||
authFlow.map((_, i) => `<i class="${i <= index ? 'done' : ''}"></i>`).join('') +
|
||
`<span>Step ${index + 1} of ${authFlow.length}</span>`;
|
||
}
|
||
|
||
function loginStep(key) {
|
||
const meta = AUTH_SCREENS[key] || AUTH_SCREENS['step-password'];
|
||
const targetId = meta.screen || key;
|
||
const body = $('#auth-body');
|
||
const previousHeight = body.offsetHeight;
|
||
|
||
authCurrent = key;
|
||
$('#auth-title').textContent = meta.title;
|
||
$('#auth-subtitle').textContent = meta.subtitle || '';
|
||
$('#auth-subtitle').hidden = !meta.subtitle;
|
||
$('#auth-back').hidden = !meta.back;
|
||
renderAuthRail(key);
|
||
loginError('');
|
||
|
||
$$('.auth-screen').forEach((el) => {
|
||
el.hidden = el.id !== targetId;
|
||
el.classList.remove('entering');
|
||
});
|
||
|
||
const entering = document.getElementById(targetId);
|
||
entering.classList.add('entering');
|
||
|
||
// Animate between the old and new heights so the card feels like it is turning
|
||
// a page instead of jumping.
|
||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
if (!reduce && previousHeight) {
|
||
const nextHeight = body.scrollHeight;
|
||
body.style.height = `${previousHeight}px`;
|
||
requestAnimationFrame(() => {
|
||
body.style.height = `${nextHeight}px`;
|
||
});
|
||
body.addEventListener(
|
||
'transitionend',
|
||
() => {
|
||
body.style.height = '';
|
||
},
|
||
{ once: true }
|
||
);
|
||
}
|
||
|
||
const focusTarget = meta.focus ? $(meta.focus) : entering.querySelector('input');
|
||
if (focusTarget) setTimeout(() => focusTarget.focus(), 60);
|
||
}
|
||
|
||
$('#auth-back').addEventListener('click', async () => {
|
||
if ((AUTH_SCREENS[authCurrent] || {}).back === 'restart') {
|
||
await api('/logout', { method: 'POST' }).catch(() => {});
|
||
$('#twofa-code').value = '';
|
||
$('#login-password').value = '';
|
||
setAuthFlow(['step-password']);
|
||
loginStep('step-password');
|
||
}
|
||
});
|
||
|
||
/* ---------------------------------------------------------- login flow */
|
||
|
||
$('#step-password').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
loginError('');
|
||
const button = event.target.querySelector('button[type="submit"]');
|
||
button.disabled = true;
|
||
try {
|
||
const result = await api('/login', {
|
||
method: 'POST',
|
||
body: { email: $('#login-email').value, password: $('#login-password').value },
|
||
});
|
||
handleLoginResult(result);
|
||
} catch (err) {
|
||
loginError(err.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
|
||
function handleLoginResult(result) {
|
||
if (result.status === 'twoFactorSetup') {
|
||
$('#twofa-setup').hidden = false;
|
||
$('#twofa-qr').src = result.qr;
|
||
$('#twofa-secret').textContent = result.secret;
|
||
$('#twofa-label').textContent = '6 digit code from the app';
|
||
// Enrolling always adds a recovery codes page, and the server tells us whether a
|
||
// password change follows. Declaring the whole path now means the step counter
|
||
// never changes its total halfway through.
|
||
setAuthFlow([
|
||
'step-password',
|
||
'step-2fa-setup',
|
||
'step-recovery',
|
||
...(result.passwordChangeToFollow ? ['step-newpassword'] : []),
|
||
]);
|
||
loginStep('step-2fa-setup');
|
||
return;
|
||
}
|
||
if (result.status === 'twoFactorRequired') {
|
||
$('#twofa-setup').hidden = true;
|
||
$('#twofa-label').textContent = '6 digit code, or a recovery code';
|
||
setAuthFlow([
|
||
'step-password',
|
||
'step-2fa-verify',
|
||
...(result.passwordChangeToFollow ? ['step-newpassword'] : []),
|
||
]);
|
||
loginStep('step-2fa-verify');
|
||
return;
|
||
}
|
||
if (result.recoveryCodes) {
|
||
recoveryCodes = result.recoveryCodes;
|
||
$('#recovery-list').innerHTML = recoveryCodes.map((c) => `<li>${esc(c)}</li>`).join('');
|
||
$('#recovery-done').dataset.next = result.status;
|
||
if (result.status === 'passwordChangeRequired' && !authFlow.includes('step-newpassword')) {
|
||
setAuthFlow([...authFlow, 'step-newpassword']);
|
||
}
|
||
loginStep('step-recovery');
|
||
return;
|
||
}
|
||
if (result.status === 'passwordChangeRequired') {
|
||
if (!authFlow.includes('step-newpassword')) setAuthFlow([...authFlow, 'step-newpassword']);
|
||
loginStep('step-newpassword');
|
||
return;
|
||
}
|
||
if (result.usedRecoveryCode) {
|
||
toast(`Recovery code used. ${result.recoveryCodesLeft} left — reset two factor soon.`);
|
||
}
|
||
boot();
|
||
}
|
||
|
||
$('#step-2fa').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
loginError('');
|
||
const button = event.target.querySelector('button[type="submit"]');
|
||
button.disabled = true;
|
||
try {
|
||
const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } });
|
||
$('#twofa-code').value = '';
|
||
handleLoginResult(result);
|
||
} catch (err) {
|
||
loginError(err.message);
|
||
$('#twofa-code').select();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
|
||
let recoveryCodes = [];
|
||
|
||
$('#recovery-copy').addEventListener('click', async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(recoveryCodes.join('\n'));
|
||
toast('Recovery codes copied.');
|
||
} catch {
|
||
toast('Copying was blocked. Write them down instead.', true);
|
||
}
|
||
});
|
||
|
||
$('#recovery-done').addEventListener('click', () => {
|
||
if ($('#recovery-done').dataset.next === 'passwordChangeRequired') loginStep('step-newpassword');
|
||
else boot();
|
||
});
|
||
|
||
$('#step-newpassword').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
loginError('');
|
||
if ($('#pw-new').value !== $('#pw-again').value) return loginError('The two new passwords differ.');
|
||
try {
|
||
await api('/account/password', {
|
||
method: 'POST',
|
||
body: { currentPassword: $('#pw-current').value, newPassword: $('#pw-new').value },
|
||
});
|
||
toast('Password updated.');
|
||
boot();
|
||
} catch (err) {
|
||
loginError(err.message);
|
||
}
|
||
});
|
||
|
||
$('#logout').addEventListener('click', async () => {
|
||
await api('/logout', { method: 'POST' });
|
||
location.reload();
|
||
});
|
||
|
||
/* ---------------------------------------------------------------- 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>
|
||
</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}`));
|
||
})
|
||
);
|
||
}
|
||
|
||
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 } = {}) {
|
||
$('#modal-title').textContent = title;
|
||
$('#modal-body').innerHTML = bodyHtml;
|
||
$('#modal-save').textContent = saveLabel;
|
||
$('#modal-cancel').hidden = hideCancel;
|
||
const modal = $('#modal');
|
||
modal.returnValue = '';
|
||
modal.showModal();
|
||
modal.onclose = () => {
|
||
$('#modal-cancel').hidden = false;
|
||
$('#modal-save').textContent = 'Save';
|
||
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
||
};
|
||
}
|
||
|
||
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)}
|
||
${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;
|
||
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);
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
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' : ''}`
|
||
: 'Off'
|
||
}</dd>
|
||
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</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')
|
||
)
|
||
);
|
||
}
|
||
|
||
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>
|
||
<div class="modal-row">
|
||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||
</div>
|
||
<label class="inline"><input type="checkbox" name="showPhoto" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||
${field('Line printed at the bottom', 'note', site.badge.note)}
|
||
<p class="hint">Common label sizes: 86 × 54 mm (card), 100 × 62 mm and 62 × 29 mm (Brother),
|
||
101 × 54 mm (Dymo). Preview before you commit a roll to it.</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'),
|
||
note: data.note,
|
||
},
|
||
},
|
||
});
|
||
toast('Site saved.');
|
||
loadSites();
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
$('#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');
|
||
|
||
if (!session.admin) {
|
||
$('#login').hidden = false;
|
||
$('#console').hidden = true;
|
||
if (session.domainRule) {
|
||
$('#domain-rule').textContent = `Use your ${session.domainRule} address.`;
|
||
$('#domain-rule').hidden = false;
|
||
}
|
||
if (session.setupNeeded) {
|
||
$('#setup-message').textContent =
|
||
'No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first one.';
|
||
setAuthFlow(['step-setup']);
|
||
loginStep('step-setup');
|
||
} else {
|
||
setAuthFlow(['step-password']);
|
||
loginStep('step-password');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (session.mustChangePassword) {
|
||
$('#login').hidden = false;
|
||
$('#console').hidden = true;
|
||
if (!authFlow.includes('step-newpassword')) setAuthFlow([...authFlow, 'step-newpassword']);
|
||
loginStep('step-newpassword');
|
||
return;
|
||
}
|
||
|
||
me = { ...session.user, domainRule: session.domainRule };
|
||
$('#login').hidden = true;
|
||
$('#console').hidden = false;
|
||
$('#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();
|