// FlyFX admin panel: a dependency-free single-page app. // // Safety rules for this file: // * Every value from the API is inserted with textContent (via h()), never // innerHTML, so data stored in the database cannot inject markup or script. // * No inline styles or scripts: nginx sends a CSP with style-src/script-src 'self'. // * The session lives in an HttpOnly cookie this script cannot read; every // request carries X-FlyFX-Admin, which the API requires on state changes. 'use strict' const API = '/api/admin/v1' const app = document.getElementById('app') // ------------------------------------------------------------------ helpers function h(tag, attrs, ...children) { const el = document.createElement(tag) for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined || v === false) continue if (k === 'class') el.className = v else if (k.startsWith('on')) el.addEventListener(k.slice(2), v) else if (v === true) el.setAttribute(k, '') else el.setAttribute(k, String(v)) } append(el, children) return el } function append(el, ...children) { for (const c of children.flat(Infinity)) { if (c === null || c === undefined || c === false) continue el.append(c instanceof Node ? c : document.createTextNode(String(c))) } return el } const SVG_NS = 'http://www.w3.org/2000/svg' function s(tag, attrs, ...children) { const el = document.createElementNS(SVG_NS, tag) for (const [k, v] of Object.entries(attrs || {})) { if (k.startsWith('on')) el.addEventListener(k.slice(2), v) else el.setAttribute(k, String(v)) } for (const c of children.flat(Infinity)) if (c) el.append(c instanceof Node ? c : document.createTextNode(String(c))) return el } class ApiError extends Error { constructor(status, body) { super((body && body.message) || `Request failed (${status})`) this.status = status this.body = body || {} } } async function api(method, path, body) { const res = await fetch(API + path, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-FlyFX-Admin': '1' }, body: body === undefined ? undefined : JSON.stringify(body), }) let data = null try { data = await res.json() } catch { /* empty or non-JSON body */ } if (!res.ok) { const err = new ApiError(res.status, data) // A session that expired or was revoked mid-use: back to sign-in. if (res.status === 401 && !path.startsWith('/auth/login')) { state.admin = null; render() } if (res.status === 403 && err.message === 'Password change required') { state.admin.mustChangePassword = true; render() } throw err } return data } const nf = new Intl.NumberFormat() const fmtNum = n => nf.format(n) const dtf = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) const df = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }) function fmtDate(v, dateOnly) { if (!v) return '—' const d = new Date(v) return isNaN(d) ? String(v) : (dateOnly ? df : dtf).format(d) } // Status text always shows next to its color, never color alone. function statusBadge(status) { const v = String(status || 'unknown').toLowerCase() const tone = ['active', 'success', 'successful', 'completed', 'paid'].includes(v) ? 'good' : ['non-renewing', 'attention', 'pending', 'processing'].includes(v) ? 'warn' : ['failed', 'cancelled', 'canceled', 'expired', 'abandoned', 'reversed'].includes(v) ? 'bad' : '' return h('span', { class: `badge ${tone}` }, status || 'unknown') } function errorBox(err) { return h('div', { class: 'error', role: 'alert' }, err instanceof Error ? err.message : String(err)) } function setBusy(form, busy) { for (const el of form.querySelectorAll('button, input, select')) el.disabled = busy } // ------------------------------------------------------------------ state & routing const state = { admin: undefined } // undefined = not checked yet, null = signed out const ROUTES = [ { path: 'dashboard', label: 'Dashboard', view: viewDashboard }, { path: 'users', label: 'Users', view: viewUsers }, { path: 'payments', label: 'Payments', view: viewPayments }, { path: 'subscriptions', label: 'Subscriptions', view: viewSubscriptions }, { path: 'rates', label: 'Parallel rates', view: viewRates }, { path: 'audit', label: 'Audit log', view: viewAudit }, { path: 'admins', label: 'Admins', view: viewAdmins, superadmin: true }, { path: 'settings', label: 'Settings', view: viewSettings }, ] function parseHash() { const raw = location.hash.replace(/^#\/?/, '') const [pathPart, query = ''] = raw.split('?') const parts = pathPart.split('/').filter(Boolean) return { parts, params: new URLSearchParams(query) } } function go(path, params) { const q = params && [...params].length ? `?${params}` : '' location.hash = `#/${path}${q}` } window.addEventListener('hashchange', () => render()) async function boot() { try { state.admin = (await api('GET', '/auth/me')).admin } catch { state.admin = null } render() } function render() { if (state.admin === undefined) return app.replaceChildren() if (!state.admin) return app.append(viewLogin()) if (state.admin.mustChangePassword) return app.append(viewForcedPasswordChange()) const { parts, params } = parseHash() const visible = ROUTES.filter(r => !r.superadmin || state.admin.role === 'superadmin') const route = visible.find(r => r.path === parts[0]) if (!route) return go('dashboard') const main = h('main', { class: 'main' }) app.append(h('div', { class: 'shell' }, h('aside', { class: 'sidebar' }, h('div', { class: 'brand' }, 'FlyFX ', h('span', {}, 'Admin')), h('nav', { class: 'nav', 'aria-label': 'Main' }, visible.map(r => h('a', { href: `#/${r.path}`, class: r === route ? 'active' : '', 'aria-current': r === route ? 'page' : null }, r.label))), h('div', { class: 'sidebar-foot' }, h('div', { class: 'who' }, state.admin.name), h('div', { class: 'muted small' }, `${state.admin.username} · ${state.admin.role}`), h('button', { class: 'link small', onclick: logout }, 'Sign out')), ), main)) route.view(main, parts.slice(1), params) } async function logout() { try { await api('POST', '/auth/logout') } catch { /* already signed out */ } state.admin = null location.hash = '' render() } // ------------------------------------------------------------------ auth screens function viewLogin() { let needCode = false const err = h('div') const username = h('input', { name: 'username', autocomplete: 'username', required: true, autofocus: true }) const password = h('input', { name: 'password', type: 'password', autocomplete: 'current-password', required: true }) const code = h('input', { name: 'code', inputmode: 'numeric', autocomplete: 'one-time-code', pattern: '\\d{6}', maxlength: 6 }) const codeField = h('label', { class: 'field hidden' }, 'Authenticator code', code) const form = h('form', { class: 'form', onsubmit: async e => { e.preventDefault() err.replaceChildren() setBusy(form, true) try { const body = { username: username.value.trim(), password: password.value } if (needCode) body.code = code.value.trim() state.admin = (await api('POST', '/auth/login', body)).admin render() } catch (ex) { setBusy(form, false) if (ex.body && ex.body.totpRequired && !needCode) { needCode = true codeField.classList.remove('hidden') code.required = true code.focus() return } err.replaceChildren(errorBox(ex)) } } }, h('label', { class: 'field' }, 'Username', username), h('label', { class: 'field' }, 'Password', password), codeField, err, h('button', { class: 'primary', type: 'submit' }, 'Sign in')) return h('div', { class: 'auth' }, h('div', { class: 'card' }, h('h1', {}, 'FlyFX Admin'), h('p', { class: 'muted' }, 'Sign in to continue.'), form)) } function passwordForm({ onDone, submitLabel }) { const err = h('div') const current = h('input', { type: 'password', autocomplete: 'current-password', required: true }) const next = h('input', { type: 'password', autocomplete: 'new-password', minlength: 12, maxlength: 72, required: true }) const confirm = h('input', { type: 'password', autocomplete: 'new-password', required: true }) const form = h('form', { class: 'form', onsubmit: async e => { e.preventDefault() err.replaceChildren() if (next.value !== confirm.value) return err.replaceChildren(errorBox('The new passwords do not match')) setBusy(form, true) try { await api('POST', '/auth/password', { currentPassword: current.value, newPassword: next.value }) onDone() } catch (ex) { err.replaceChildren(errorBox(ex)) } finally { setBusy(form, false) } } }, h('label', { class: 'field' }, 'Current password', current), h('label', { class: 'field' }, 'New password', next, h('span', { class: 'muted small' }, '12 to 72 characters')), h('label', { class: 'field' }, 'Repeat new password', confirm), err, h('button', { class: 'primary', type: 'submit' }, submitLabel)) return form } function viewForcedPasswordChange() { return h('div', { class: 'auth' }, h('div', { class: 'card' }, h('h1', {}, 'Choose a new password'), h('p', { class: 'muted' }, 'You signed in with a temporary password. Replace it to continue.'), passwordForm({ submitLabel: 'Save and continue', onDone: async () => { state.admin = (await api('GET', '/auth/me')).admin; go('dashboard'); render() }, }), h('p', { class: 'small' }, h('button', { class: 'link', onclick: logout }, 'Sign out')))) } // ------------------------------------------------------------------ shared list view // A paged, searchable table backed by one list endpoint. function listView(main, { title, endpoint, params, searchPlaceholder, statuses, columns, onRow, routePath }) { const q = params.get('q') || '' const status = params.get('status') || '' const page = Math.max(1, parseInt(params.get('page') || '1', 10) || 1) const search = h('input', { type: 'search', placeholder: searchPlaceholder, value: q, 'aria-label': 'Search' }) const statusSel = statuses && h('select', { 'aria-label': 'Status' }, h('option', { value: '' }, 'All statuses'), statuses.map(st => h('option', { value: st, selected: st === status }, st))) const submit = (p = 1) => { const next = new URLSearchParams() if (search.value.trim()) next.set('q', search.value.trim()) if (statusSel && statusSel.value) next.set('status', statusSel.value) if (p > 1) next.set('page', String(p)) go(routePath, next) } const body = h('div', {}, h('div', { class: 'empty' }, 'Loading…')) append(main, h('div', { class: 'page-head' }, h('h1', {}, title), searchPlaceholder || statusSel ? h('form', { class: 'toolbar', onsubmit: e => { e.preventDefault(); submit() } }, searchPlaceholder && search, statusSel, h('button', { type: 'submit' }, 'Filter')) : null), h('div', { class: 'card' }, body)) if (statusSel) statusSel.addEventListener('change', () => submit()) const qs = new URLSearchParams({ page: String(page), limit: '25' }) if (q) qs.set('q', q) if (status) qs.set('status', status) api('GET', `${endpoint}?${qs}`).then(res => { body.replaceChildren(table(columns, res.data, onRow), pager(res, submit)) }).catch(ex => body.replaceChildren(errorBox(ex))) } function table(columns, rows, onRow) { if (!rows.length) return h('div', { class: 'empty' }, 'Nothing to show.') return h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, columns.map(c => h('th', { class: c.num ? 'num' : '', scope: 'col' }, c.label)))), h('tbody', {}, rows.map(row => h('tr', { class: onRow ? 'clickable' : '', tabindex: onRow ? 0 : null, onclick: onRow ? () => onRow(row) : null, onkeydown: onRow ? e => { if (e.key === 'Enter') onRow(row) } : null, }, columns.map(c => h('td', { class: [c.num ? 'num' : '', c.cls || ''].join(' ').trim() }, c.render ? c.render(row) : (row[c.key] ?? '—')))))))) } function pager(res, submit) { const pages = Math.max(1, Math.ceil(res.total / res.limit)) return h('div', { class: 'pager' }, h('span', {}, `${fmtNum(res.total)} total · page ${res.page} of ${pages}`), h('div', { class: 'toolbar' }, h('button', { disabled: res.page <= 1, onclick: () => submit(res.page - 1) }, 'Previous'), h('button', { disabled: res.page >= pages, onclick: () => submit(res.page + 1) }, 'Next'))) } // ------------------------------------------------------------------ dashboard function viewDashboard(main) { const body = h('div', {}, h('div', { class: 'empty' }, 'Loading…')) append(main, h('div', { class: 'page-head' }, h('h1', {}, 'Dashboard')), body) api('GET', '/stats').then(({ data }) => { const paymentsTotal = data.payments30d.reduce((a, r) => a + r.count, 0) const rate = data.latestRate body.replaceChildren( h('div', { class: 'grid tiles' }, tile('Users', fmtNum(data.usersTotal), `${fmtNum(data.usersNew7d)} new in the last 7 days`), tile('Active subscriptions', fmtNum(data.subscriptionsActive), 'active or non-renewing'), tile('Payments, last 30 days', fmtNum(paymentsTotal), 'all statuses'), tile('Latest parallel rates', rate ? fmtDate(rate.created_at, true) : 'None yet', rate ? `${rate.px} · by ${rate.added_by}` : 'posted via the Telegram bot')), h('div', { class: 'grid two' }, h('div', { class: 'card' }, signupsChart(data.signups30d)), h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, 'Payments by status'), h('span', { class: 'muted small' }, 'last 30 days')), table([ { label: 'Status', render: r => statusBadge(r.status) }, { label: 'Count', num: true, render: r => fmtNum(r.count) }, ], data.payments30d)))) }).catch(ex => body.replaceChildren(errorBox(ex))) } function tile(label, value, sub) { return h('div', { class: 'card tile' }, h('div', { class: 'label' }, label), h('div', { class: 'value' }, value), sub ? h('div', { class: 'sub' }, sub) : null) } // Single-series column chart of daily signups, with hover tooltips and a // table view. Days with no signups are filled in as zero. function signupsChart(points) { const byDay = new Map(points.map(p => [String(p.day).slice(0, 10), p.count])) const days = [] const today = new Date() for (let i = 29; i >= 0; i--) { const d = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - i)) const key = d.toISOString().slice(0, 10) days.push({ key, date: d, count: byDay.get(key) || 0 }) } const total = days.reduce((a, d) => a + d.count, 0) const W = 600, H = 220, padL = 32, padR = 4, padT = 8, padB = 24 const max = Math.max(4, ...days.map(d => d.count)) const step = Math.pow(10, Math.floor(Math.log10(max))) const tick = [1, 2, 2.5, 5, 10].map(m => m * step).find(t => max / t <= 4) || step * 10 const top = Math.ceil(max / tick) * tick const y = v => padT + (H - padT - padB) * (1 - v / top) const band = (W - padL - padR) / days.length const barW = Math.min(24, band - 2) // 2px surface gap between adjacent columns const shortDate = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) const tip = h('div', { class: 'tooltip hidden', role: 'status' }) const wrap = h('div', { class: 'chart' }) const grid = [] for (let v = 0; v <= top; v += tick) { grid.push(s('line', { class: 'gridline', x1: padL, x2: W - padR, y1: y(v), y2: y(v) })) grid.push(s('text', { class: 'axis-text', x: padL - 6, y: y(v) + 4, 'text-anchor': 'end' }, fmtNum(v))) } const bars = days.map((d, i) => { const x = padL + band * i + (band - barW) / 2 const hgt = y(0) - y(d.count) // 4px rounded top, square at the baseline. const r = Math.min(4, hgt, barW / 2) const path = hgt <= 0 ? '' : `M${x},${y(0)} V${y(d.count) + r} Q${x},${y(d.count)} ${x + r},${y(d.count)} H${x + barW - r} Q${x + barW},${y(d.count)} ${x + barW},${y(d.count) + r} V${y(0)} Z` const bar = s('path', { class: 'bar', d: path }) const show = () => { bar.classList.add('on') tip.textContent = `${shortDate.format(d.date)}: ${fmtNum(d.count)} signup${d.count === 1 ? '' : 's'}` tip.classList.remove('hidden') const box = wrap.getBoundingClientRect() const svgBox = svg.getBoundingClientRect() tip.style.left = `${(x + barW / 2) / W * svgBox.width + svgBox.left - box.left}px` tip.style.top = `${y(d.count) / H * svgBox.height + svgBox.top - box.top}px` } const hide = () => { bar.classList.remove('on'); tip.classList.add('hidden') } // The hit target is the whole band and full height, much bigger than the mark. const hit = s('rect', { class: 'hit', x: padL + band * i, y: padT, width: band, height: H - padT - padB, onmouseenter: show, onmouseleave: hide }) return [hit, bar] }) const labels = [0, 7, 14, 21, 29].map(i => s('text', { class: 'axis-text', x: padL + band * i + band / 2, y: H - 6, 'text-anchor': i === 29 ? 'end' : i === 0 ? 'start' : 'middle', }, shortDate.format(days[i].date))) const svg = s('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': `Daily signups over the last 30 days, ${total} in total` }, grid, bars, labels) append(wrap, svg, tip) const tableView = h('div', { class: 'hidden' }, table([ { label: 'Day', render: d => shortDate.format(d.date) }, { label: 'Signups', num: true, render: d => fmtNum(d.count) }, ], days.slice().reverse())) const toggle = h('button', { class: 'link chart-toggle', onclick: () => { const showTable = tableView.classList.contains('hidden') tableView.classList.toggle('hidden', !showTable) wrap.classList.toggle('hidden', showTable) toggle.textContent = showTable ? 'Show chart' : 'Show table' } }, 'Show table') return h('div', {}, h('div', { class: 'card-head' }, h('div', {}, h('h2', {}, 'Signups'), h('div', { class: 'muted small' }, `Last 30 days · ${fmtNum(total)} total`)), toggle), wrap, tableView) } // ------------------------------------------------------------------ data views function viewUsers(main, rest, params) { if (rest[0]) return viewUserDetail(main, rest[0]) listView(main, { title: 'Users', endpoint: '/users', routePath: 'users', params, searchPlaceholder: 'Username, email or name', columns: [ { label: 'ID', key: 'id', num: true }, { label: 'Username', key: 'username' }, { label: 'Name', key: 'fullname' }, { label: 'Email', key: 'email' }, { label: 'Terms', render: r => r.terms ? 'Accepted' : 'No' }, { label: 'Joined', render: r => fmtDate(r.created_at) }, ], onRow: r => go(`users/${r.id}`), }) } function viewUserDetail(main, id) { const body = h('div', {}, h('div', { class: 'empty' }, 'Loading…')) append(main, h('div', { class: 'page-head' }, h('div', {}, h('a', { href: '#/users', class: 'small' }, '← Users'), h('h1', {}, 'User')), null), body) api('GET', `/users/${encodeURIComponent(id)}`).then(({ data }) => { const u = data.user body.replaceChildren( h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, u.fullname || u.username)), h('dl', { class: 'kv' }, h('dt', {}, 'ID'), h('dd', {}, u.id), h('dt', {}, 'Username'), h('dd', {}, u.username || '—'), h('dt', {}, 'Email'), h('dd', {}, u.email || '—'), h('dt', {}, 'Paystack customer'), h('dd', { class: 'mono' }, u.customer_id || '—'), h('dt', {}, 'Terms accepted'), h('dd', {}, u.terms ? 'Yes' : 'No'), h('dt', {}, 'Registered devices'), h('dd', {}, fmtNum(data.deviceCount)), h('dt', {}, 'Joined'), h('dd', {}, fmtDate(u.created_at)), h('dt', {}, 'Updated'), h('dd', {}, fmtDate(u.updated_at)))), h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, 'Subscriptions')), table(SUB_COLUMNS, data.subscriptions)), h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, 'Payments')), table(PAYMENT_COLUMNS, data.payments))) }).catch(ex => body.replaceChildren(errorBox(ex))) } const PAYMENT_COLUMNS = [ { label: 'ID', key: 'id', num: true }, { label: 'User', key: 'user_info' }, { label: 'Status', render: r => statusBadge(r.status) }, { label: 'Reference', render: r => h('span', { class: 'mono' }, r.transaction_id || '—') }, { label: 'Created', render: r => fmtDate(r.created_at) }, { label: 'Updated', render: r => fmtDate(r.updated_at) }, ] const SUB_COLUMNS = [ { label: 'ID', key: 'id', num: true }, { label: 'User', key: 'username' }, { label: 'Status', render: r => statusBadge(r.sub_status) }, { label: 'Code', render: r => h('span', { class: 'mono' }, r.sub_code || '—') }, { label: 'Starts', render: r => fmtDate(r.start_date, true) }, { label: 'Ends', render: r => fmtDate(r.end_date, true) }, ] function viewPayments(main, _rest, params) { listView(main, { title: 'Payments', endpoint: '/payments', routePath: 'payments', params, searchPlaceholder: 'User or reference', statuses: ['success', 'failed', 'abandoned', 'pending', 'reversed'], columns: PAYMENT_COLUMNS, }) } function viewSubscriptions(main, _rest, params) { listView(main, { title: 'Subscriptions', endpoint: '/subscriptions', routePath: 'subscriptions', params, searchPlaceholder: 'User or subscription code', statuses: ['active', 'non-renewing', 'attention', 'cancelled', 'expired'], columns: SUB_COLUMNS, }) } function viewRates(main, _rest, params) { listView(main, { title: 'Parallel rates', endpoint: '/rates', routePath: 'rates', params, columns: [ { label: 'Posted', render: r => fmtDate(r.created_at) }, { label: 'Batch', render: r => h('span', { class: 'mono' }, r.px || '—') }, { label: 'By', key: 'added_by' }, { label: 'Rates', render: r => ratesSummary(r.data) }, ], }) // Editor goes between the page title and the history table. const editor = h('div', { class: 'card' }, h('div', { class: 'empty' }, 'Loading…')) main.insertBefore(editor, main.children[1]) append(main, h('p', { class: 'muted small' }, 'Every change here, and from the Telegram bot, is pushed to app users and recorded in the audit log.')) loadRateEditor(editor) } const naira = n => `₦${fmtNum(n)}` async function loadRateEditor(box) { let res try { res = await api('GET', '/rates/current') } catch (ex) { return box.replaceChildren(errorBox(ex)) } // Re-render the page so the editor and the history table both refresh. const reload = () => render() if (!res.editable) { return box.replaceChildren( h('div', { class: 'card-head' }, h('h2', {}, 'Change rates')), h('p', { class: 'notice' }, 'Rate editing is turned off on this server (INTERNAL_API_TOKEN is not set). Rates can still be posted through the Telegram bot.')) } const cur = res.data box.replaceChildren(cur && cur.isToday ? todayRatesEditor(cur, reload) : postRatesForm(cur, reload)) } // Today's rates exist: change one currency at a time, or add one. function todayRatesEditor(cur, reload) { const err = h('div') const save = async (currency, input, button) => { const rate = Number(input.value) if (!Number.isFinite(rate) || rate <= 0) return err.replaceChildren(errorBox(`Enter a valid rate for ${currency}`)) const was = cur.fxdata[currency] // Saving an unchanged rate would still push a notification to every user. if (was === rate) return err.replaceChildren(errorBox(`${currency} is already ${naira(rate)}`)) const question = was === undefined ? `Add ${currency} at ${naira(rate)}? App users get a push notification.` : `Change ${currency} from ${naira(was)} to ${naira(rate)}? App users get a push notification.` if (!confirm(question)) return err.replaceChildren() button.disabled = true try { await api('PUT', `/rates/current/${encodeURIComponent(currency)}`, { rate }) reload() } catch (ex) { err.replaceChildren(errorBox(ex)) button.disabled = false } } const rows = Object.entries(cur.fxdata).map(([currency, rate]) => { const input = h('input', { type: 'number', min: '0', step: 'any', value: rate, 'aria-label': `${currency} rate`, class: 'rate-input' }) const button = h('button', {}, 'Save') button.addEventListener('click', () => save(currency, input, button)) input.addEventListener('keydown', e => { if (e.key === 'Enter') button.click() }) return h('tr', {}, h('td', { class: 'mono' }, currency), h('td', { class: 'hide-narrow' }, naira(rate)), h('td', {}, h('div', { class: 'toolbar' }, input, button))) }) const newCode = h('input', { placeholder: 'ZAR', maxlength: 5, 'aria-label': 'New currency code', class: 'code-input' }) const newRate = h('input', { type: 'number', min: '0', step: 'any', placeholder: 'Rate in ₦', 'aria-label': 'New currency rate', class: 'rate-input' }) const addButton = h('button', {}, 'Add') addButton.addEventListener('click', () => { const code = newCode.value.trim().toUpperCase() if (!/^[A-Z]{3,5}$/.test(code)) return err.replaceChildren(errorBox('Currency must be 3-5 letters, e.g. ZAR')) if (code in cur.fxdata) return err.replaceChildren(errorBox(`${code} is already listed; change it above`)) save(code, newRate, addButton) }) return h('div', {}, h('div', { class: 'card-head' }, h('div', {}, h('h2', {}, "Today's rates"), h('div', { class: 'muted small' }, `Posted${cur.added_by ? ` by ${cur.added_by}` : ''} · ${cur.px}`))), h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Currency'), h('th', { scope: 'col', class: 'hide-narrow' }, 'Current'), h('th', { scope: 'col' }, 'New rate (₦)'))), h('tbody', {}, rows, h('tr', {}, h('td', {}, newCode), h('td', { class: 'muted hide-narrow' }, 'New currency'), h('td', {}, h('div', { class: 'toolbar' }, newRate, addButton)))))), err) } // Nothing posted yet today: post the full set, prefilled with the last known rates. function postRatesForm(cur, reload) { const err = h('div') const tbody = h('tbody') const addRow = (code = '', rate = '') => { const codeInput = h('input', { value: code, placeholder: 'USD', maxlength: 5, 'aria-label': 'Currency code', class: 'code-input' }) const rateInput = h('input', { type: 'number', min: '0', step: 'any', value: rate, placeholder: 'Rate in ₦', 'aria-label': 'Rate', class: 'rate-input' }) const row = h('tr', {}, h('td', {}, codeInput), h('td', {}, rateInput), h('td', {}, h('button', { type: 'button', class: 'link', 'aria-label': 'Remove row', onclick: () => row.remove() }, 'Remove'))) tbody.append(row) } const last = cur ? cur.fxdata : { USD: '', GBP: '', EUR: '' } for (const [c, r] of Object.entries(last)) addRow(c, r) const form = h('form', { onsubmit: async e => { e.preventDefault() err.replaceChildren() const fxdata = {} for (const row of tbody.children) { const [codeInput, rateInput] = row.querySelectorAll('input') const code = codeInput.value.trim().toUpperCase() if (!code && !rateInput.value) continue if (!/^[A-Z]{3,5}$/.test(code)) return err.replaceChildren(errorBox(`"${codeInput.value}" is not a currency code`)) if (code in fxdata) return err.replaceChildren(errorBox(`${code} is listed twice`)) const rate = Number(rateInput.value) if (!Number.isFinite(rate) || rate <= 0) return err.replaceChildren(errorBox(`Enter a valid rate for ${code}`)) fxdata[code] = rate } if (!Object.keys(fxdata).length) return err.replaceChildren(errorBox('Add at least one currency')) const summary = Object.entries(fxdata).map(([c, r]) => `${c} ${naira(r)}`).join(', ') if (!confirm(`Post today's rates (${summary})? Every app user with notifications gets a push.`)) return setBusy(form, true) try { await api('POST', '/rates/today', { fxdata }) reload() } catch (ex) { err.replaceChildren(errorBox(ex)) setBusy(form, false) } } }, h('div', { class: 'card-head' }, h('div', {}, h('h2', {}, "Post today's rates"), h('div', { class: 'muted small' }, cur ? `Nothing posted yet today. Prefilled with the last rates (${cur.px}).` : 'No rates have been posted before.'))), h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Currency'), h('th', { scope: 'col' }, 'Rate (₦)'), h('th', {}, ''))), tbody)), err, h('div', { class: 'toolbar form-actions' }, h('button', { type: 'button', onclick: () => addRow() }, 'Add currency'), h('button', { type: 'submit', class: 'primary' }, "Post today's rates"))) return form } function ratesSummary(data) { let obj = data if (typeof obj === 'string') { try { obj = JSON.parse(obj) } catch { return obj } } if (!obj || typeof obj !== 'object') return '—' const rates = obj.fxdata && typeof obj.fxdata === 'object' ? obj.fxdata : obj return Object.entries(rates).filter(([, v]) => typeof v === 'number' || typeof v === 'string') .map(([k, v]) => `${k} ${typeof v === 'number' ? fmtNum(v) : v}`).join(' · ') || '—' } function viewAudit(main, _rest, params) { const json = v => { if (v === null || v === undefined) return '—' const text = typeof v === 'string' ? v : JSON.stringify(v) return h('pre', { class: 'json' }, text) } listView(main, { title: 'Audit log', endpoint: '/audit', routePath: 'audit', params, searchPlaceholder: 'Actor or action', columns: [ { label: 'When', render: r => fmtDate(r.created_at) }, { label: 'Actor', key: 'user_id' }, { label: 'Action', render: r => h('span', { class: 'mono' }, r.action) }, { label: 'Record', render: r => r.table_name ? `${r.table_name} #${r.record_id ?? ''}` : '—' }, { label: 'Change', render: r => r.new_values || r.old_values ? h('div', {}, r.old_values ? json(r.old_values) : null, json(r.new_values)) : '—' }, { label: 'IP', render: r => h('span', { class: 'mono' }, r.ip_address || '—') }, ], }) } // ------------------------------------------------------------------ admins (superadmin) function viewAdmins(main) { const body = h('div', {}, h('div', { class: 'empty' }, 'Loading…')) const flash = h('div') append(main, h('div', { class: 'page-head' }, h('h1', {}, 'Admins')), flash, body, createAdminCard()) function showSecret(title, who, password) { flash.replaceChildren(h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, title)), h('p', { class: 'muted' }, `Give this temporary password to ${who} through a private channel. It is shown only once and must be changed at first sign-in.`), h('div', { class: 'secret' }, password), h('p', {}, h('button', { onclick: () => flash.replaceChildren() }, 'Done')))) flash.scrollIntoView({ behavior: 'smooth', block: 'start' }) } async function act(fn) { try { await fn(); load() } catch (ex) { flash.replaceChildren(errorBox(ex)) } } // One compact menu per row instead of four buttons, so the table fits. function adminActions(a) { const actions = { role: { label: a.role === 'superadmin' ? 'Make admin' : 'Make superadmin', run: () => act(() => api('PATCH', `/admins/${a.id}`, { role: a.role === 'superadmin' ? 'admin' : 'superadmin' })), }, toggle: { label: a.disabled ? 'Enable' : 'Disable', confirm: a.disabled ? null : `Disable ${a.username}? They are signed out immediately.`, run: () => act(() => api('PATCH', `/admins/${a.id}`, { disabled: !a.disabled })), }, password: { label: 'Reset password', confirm: `Reset ${a.username}'s password? Their current sessions end.`, run: () => act(async () => showSecret('Password reset', a.username, (await api('POST', `/admins/${a.id}/reset-password`)).tempPassword)), }, } if (a.totpEnabled) actions.totp = { label: 'Reset 2FA', confirm: `Turn off two-factor sign-in for ${a.username}? Do this only if they lost their device.`, run: () => act(() => api('POST', `/admins/${a.id}/reset-2fa`)), } const menu = h('select', { 'aria-label': `Actions for ${a.username}`, onchange: () => { const chosen = actions[menu.value] menu.value = '' if (!chosen || (chosen.confirm && !confirm(chosen.confirm))) return chosen.run() } }, h('option', { value: '' }, 'Actions…'), Object.entries(actions).map(([key, x]) => h('option', { value: key }, x.label))) return menu } function load() { api('GET', '/admins').then(({ data }) => { body.replaceChildren(h('div', { class: 'card' }, table([ { label: 'Admin', render: a => h('div', {}, h('div', {}, a.name), h('div', { class: 'muted small' }, `${a.username} · ${a.email}`)) }, { label: 'Role', key: 'role' }, { label: 'Status', render: a => a.disabled ? statusBadge('disabled') : a.mustChangePassword ? h('span', { class: 'badge warn' }, 'password pending') : h('span', { class: 'badge good' }, 'active') }, { label: '2FA', render: a => a.totpEnabled ? 'On' : 'Off' }, { label: 'Last sign-in', render: a => fmtDate(a.lastLoginAt) }, { label: '', render: a => a.id === state.admin.id ? h('span', { class: 'muted small' }, 'you') : adminActions(a) }, ], data))) }).catch(ex => body.replaceChildren(errorBox(ex))) } function createAdminCard() { const err = h('div') const username = h('input', { required: true, pattern: '[a-z0-9._\\-]{3,50}', autocomplete: 'off' }) const name = h('input', { required: true, autocomplete: 'off' }) const email = h('input', { type: 'email', required: true, autocomplete: 'off' }) const role = h('select', {}, h('option', { value: 'admin' }, 'Admin: read-only access to app data'), h('option', { value: 'superadmin' }, 'Superadmin: also manages admins')) const form = h('form', { class: 'form', onsubmit: async e => { e.preventDefault() err.replaceChildren() setBusy(form, true) try { const res = await api('POST', '/admins', { username: username.value.trim().toLowerCase(), name: name.value.trim(), email: email.value.trim(), role: role.value }) form.reset() showSecret('Admin created', res.admin.username, res.tempPassword) load() } catch (ex) { err.replaceChildren(errorBox(ex)) } finally { setBusy(form, false) } } }, h('div', { class: 'grid tiles' }, h('label', { class: 'field' }, 'Username', username), h('label', { class: 'field' }, 'Full name', name), h('label', { class: 'field' }, 'Email', email), h('label', { class: 'field' }, 'Role', role)), err, h('div', {}, h('button', { class: 'primary', type: 'submit' }, 'Create admin'))) return h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, 'Add an admin')), form) } load() } // ------------------------------------------------------------------ settings function viewSettings(main) { const twofa = h('div') append(main, h('div', { class: 'page-head' }, h('h1', {}, 'Settings')), h('div', { class: 'grid two' }, h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, 'Change password')), passwordForm({ submitLabel: 'Change password', onDone: () => { alert('Password changed. Other sessions were signed out.'); render() } })), h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', {}, 'Two-factor sign-in')), twofa))) renderTwoFactor(twofa) } function renderTwoFactor(box) { const err = h('div') if (state.admin.totpEnabled) { const password = h('input', { type: 'password', autocomplete: 'current-password', required: true }) const code = h('input', { inputmode: 'numeric', autocomplete: 'one-time-code', pattern: '\\d{6}', maxlength: 6, required: true }) const form = h('form', { class: 'form', onsubmit: async e => { e.preventDefault() setBusy(form, true) try { await api('POST', '/auth/2fa/disable', { password: password.value, code: code.value.trim() }) state.admin.totpEnabled = false renderTwoFactor(box) } catch (ex) { err.replaceChildren(errorBox(ex)); setBusy(form, false) } } }, h('p', { class: 'muted' }, h('span', { class: 'badge good' }, 'On'), ' Sign-in asks for a code from your authenticator app.'), h('label', { class: 'field' }, 'Password', password), h('label', { class: 'field' }, 'Current code', code), err, h('div', {}, h('button', { class: 'danger', type: 'submit' }, 'Turn off'))) return box.replaceChildren(form) } const start = h('button', { class: 'primary', onclick: async () => { start.disabled = true try { const res = await api('POST', '/auth/2fa/setup') const code = h('input', { inputmode: 'numeric', autocomplete: 'one-time-code', pattern: '\\d{6}', maxlength: 6, required: true, autofocus: true }) const form = h('form', { class: 'form', onsubmit: async e => { e.preventDefault() setBusy(form, true) try { await api('POST', '/auth/2fa/enable', { code: code.value.trim() }) state.admin.totpEnabled = true renderTwoFactor(box) } catch (ex) { err.replaceChildren(errorBox(ex)); setBusy(form, false) } } }, h('p', { class: 'muted' }, 'Scan this with an authenticator app (1Password, Google Authenticator, Authy), then enter the 6-digit code it shows.'), h('img', { src: res.qr, alt: 'QR code for your authenticator app', width: 220, height: 220 }), h('div', { class: 'muted small' }, 'Can’t scan? Enter this key: ', h('span', { class: 'mono' }, res.secret)), h('label', { class: 'field' }, 'Code', code), err, h('div', {}, h('button', { class: 'primary', type: 'submit' }, 'Turn on'))) box.replaceChildren(form) code.focus() } catch (ex) { err.replaceChildren(errorBox(ex)); start.disabled = false } } }, 'Set up') box.replaceChildren( h('p', { class: 'notice' }, 'Two-factor sign-in is off. Admin accounts can see every user and payment, so turn it on.'), err, h('div', {}, start)) } boot()