Add admin token-protected REST API and web UI for managing codes/members

- Require ADMIN_TOKEN bearer auth on /members and new /schedule CRUD routes
- Validate JSON body shape and reject empty/malformed writes so a
  bad multipart POST can no longer wipe authorized.json
- Make saveJSON atomic (tmp -> rename) with a one-generation .bak file
- Serve a self-contained admin SPA at /admin (public/admin.html) for
  editing the discount schedule and authorized members without hand-
  editing JSON, with a /preview endpoint that renders the bot's
  fallback text for draft entries
- Dockerfile now copies public/ into the image

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-06 11:50:21 -04:00
parent a64f82a2ea
commit 086edd5fe0
3 changed files with 947 additions and 26 deletions

View file

@ -11,6 +11,7 @@ RUN npm ci --only=production
# Copy only the application code (NO config folder) # Copy only the application code (NO config folder)
COPY index.js ./ COPY index.js ./
COPY public ./public
# ====================== PRODUCTION STAGE ====================== # ====================== PRODUCTION STAGE ======================
FROM node:20-alpine AS production FROM node:20-alpine AS production
@ -25,6 +26,7 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/index.js ./ COPY --from=builder --chown=nodejs:nodejs /app/index.js ./
COPY --from=builder --chown=nodejs:nodejs /app/public ./public
# Create logs directory (will be mounted) # Create logs directory (will be mounted)
RUN mkdir -p /app/logs && chown nodejs:nodejs /app/logs RUN mkdir -p /app/logs && chown nodejs:nodejs /app/logs

317
index.js
View file

@ -15,6 +15,7 @@ if (process.env.NODE_ENV === 'development') {
import fs from 'fs/promises'; import fs from 'fs/promises';
import fsSync from 'fs'; import fsSync from 'fs';
import path from 'path'; import path from 'path';
import crypto from 'crypto';
import framework from 'webex-node-bot-framework'; import framework from 'webex-node-bot-framework';
import express from 'express'; import express from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
@ -22,14 +23,20 @@ import fetch from 'node-fetch';
import cron from 'node-cron'; import cron from 'node-cron';
let config = {}; let config = {};
let schedule = {}; let schedule = [];
let authorizedMembers = []; let authorizedMembers = [];
let responded = false; let responded = false;
let Framework = null; let Framework = null;
const CONFIG_DIR = './config';
const SCHEDULE_PATH = path.join(CONFIG_DIR, 'schedule.json');
const AUTHORIZED_PATH = path.join(CONFIG_DIR, 'authorized.json');
const PUBLIC_DIR = path.resolve('./public');
const app = express(); const app = express();
app.use(bodyParser.json()); app.disable('x-powered-by');
app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json({ limit: '512kb' }));
app.use(bodyParser.urlencoded({ extended: false, limit: '512kb' }));
// Helper: Safe async JSON load // Helper: Safe async JSON load
async function loadJSON(filePath) { async function loadJSON(filePath) {
@ -42,18 +49,138 @@ async function loadJSON(filePath) {
} }
} }
// Helper: Safe async JSON save // Helper: Safe async JSON save (atomic + one-generation .bak rollback)
async function saveJSON(jsonObject, filePath) { async function saveJSON(jsonObject, filePath) {
try {
const jsonData = JSON.stringify(jsonObject, null, 4); const jsonData = JSON.stringify(jsonObject, null, 4);
await fs.writeFile(filePath, jsonData, 'utf8'); const tmpPath = `${filePath}.tmp`;
logger('saveJSON', `Successfully wrote ${filePath}`); const bakPath = `${filePath}.bak`;
try {
await fs.writeFile(tmpPath, jsonData, 'utf8');
try {
await fs.copyFile(filePath, bakPath);
} catch (err) { } catch (err) {
logger('saveJSON', `Error writing ${filePath}: ${err.message}`); if (err.code !== 'ENOENT') throw err;
}
await fs.rename(tmpPath, filePath);
logger('saveJSON', `Successfully wrote ${filePath} (backup at ${bakPath})`);
} catch (err) {
logger('saveJSON', `Error writing ${filePath}: ${err.message}`, 'ERROR');
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
throw err; throw err;
} }
} }
// ====================== VALIDATION ======================
const MAX_MEMBERS = 5000;
const MAX_MEMBER_LEN = 320;
const MAX_SCHEDULE_ENTRIES = 240;
const MAX_STRING_LEN = 2000;
function isPlainString(v, { allowEmpty = false, max = MAX_STRING_LEN } = {}) {
if (typeof v !== 'string') return false;
if (!allowEmpty && v.trim() === '') return false;
if (v.length > max) return false;
// no NUL / control chars other than \n \r \t
return !/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(v);
}
function validateAuthorized(input) {
if (!Array.isArray(input)) return { ok: false, error: 'Body must be a JSON array of usernames/emails.' };
if (input.length === 0) return { ok: false, error: 'Refusing to save an empty authorized list.' };
if (input.length > MAX_MEMBERS) return { ok: false, error: `Too many entries (max ${MAX_MEMBERS}).` };
const cleaned = [];
const seen = new Set();
for (let i = 0; i < input.length; i++) {
const item = input[i];
if (typeof item !== 'string') return { ok: false, error: `Entry ${i} is not a string.` };
const norm = item.trim().toLowerCase();
if (!norm) return { ok: false, error: `Entry ${i} is empty.` };
if (norm.length > MAX_MEMBER_LEN) return { ok: false, error: `Entry ${i} exceeds ${MAX_MEMBER_LEN} chars.` };
if (!isPlainString(norm, { max: MAX_MEMBER_LEN })) return { ok: false, error: `Entry ${i} has invalid characters.` };
if (!seen.has(norm)) { seen.add(norm); cleaned.push(norm); }
}
cleaned.sort();
return { ok: true, value: cleaned };
}
// Shape required by buildCard() — every code/message field must be present.
const SCHEDULE_SHAPE = [
['aeAerie', 'usrow'],
['aeAerie', 'mexico'],
['toddSnyder', 'usrow', 'tstc'],
['toddSnyder', 'usrow', 'thirdparty'],
['unsubscribed', 'usrow'],
['unsubscribed', 'thirdparty'],
['unsubscribed', 'giftcard']
];
function getAt(obj, pathArr) {
return pathArr.reduce((acc, k) => (acc == null ? acc : acc[k]), obj);
}
function validateScheduleEntry(entry, i = 0) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return { ok: false, error: `Entry ${i} must be an object.` };
}
if (!isPlainString(entry.month, { max: 60 })) return { ok: false, error: `Entry ${i}: "month" is required.` };
if (!isPlainString(entry.start, { max: 40 })) return { ok: false, error: `Entry ${i}: "start" is required.` };
if (!isPlainString(entry.end, { max: 40 })) return { ok: false, error: `Entry ${i}: "end" is required.` };
const startMs = Date.parse(entry.start);
const endMs = Date.parse(entry.end);
if (isNaN(startMs)) return { ok: false, error: `Entry ${i}: "start" is not a parseable date (${entry.start}).` };
if (isNaN(endMs)) return { ok: false, error: `Entry ${i}: "end" is not a parseable date (${entry.end}).` };
if (endMs < startMs) return { ok: false, error: `Entry ${i}: "end" is before "start".` };
for (const shape of SCHEDULE_SHAPE) {
const node = getAt(entry, shape);
const label = shape.join('.');
if (!node || typeof node !== 'object') {
return { ok: false, error: `Entry ${i}: missing "${label}".` };
}
if (!isPlainString(node.code, { max: 200 })) {
return { ok: false, error: `Entry ${i}: "${label}.code" is required.` };
}
if (typeof node.message !== 'string' || node.message.length > MAX_STRING_LEN) {
return { ok: false, error: `Entry ${i}: "${label}.message" must be a string ≤ ${MAX_STRING_LEN} chars.` };
}
}
return { ok: true };
}
function validateScheduleArray(input) {
if (!Array.isArray(input)) return { ok: false, error: 'Schedule must be a JSON array.' };
if (input.length === 0) return { ok: false, error: 'Refusing to save an empty schedule.' };
if (input.length > MAX_SCHEDULE_ENTRIES) return { ok: false, error: `Too many schedule entries (max ${MAX_SCHEDULE_ENTRIES}).` };
for (let i = 0; i < input.length; i++) {
const r = validateScheduleEntry(input[i], i);
if (!r.ok) return r;
}
return { ok: true, value: input };
}
// ====================== ADMIN AUTH ======================
function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function requireAdmin(req, res, next) {
const expected = process.env.ADMIN_TOKEN;
if (!expected) {
return res.status(503).json({ error: 'Admin endpoints are disabled: ADMIN_TOKEN is not configured on the server.' });
}
const header = req.get('authorization') || '';
const m = header.match(/^Bearer\s+(.+)$/i);
const provided = m ? m[1].trim() : (req.get('x-admin-token') || '').trim();
if (!provided || !safeEqual(provided, expected)) {
return res.status(401).json({ error: 'Unauthorized.' });
}
next();
}
// Logger // Logger
function logger(section, message, level = 'INFO') { function logger(section, message, level = 'INFO') {
const now = new Date().toLocaleString(); const now = new Date().toLocaleString();
@ -63,38 +190,169 @@ function logger(section, message, level = 'INFO') {
// Load configs on startup // Load configs on startup
async function loadConfigs() { async function loadConfigs() {
try { try {
config = await loadJSON('./config/config.json'); config = await loadJSON(path.join(CONFIG_DIR, 'config.json'));
schedule = await loadJSON('./config/schedule.json'); const rawSchedule = await loadJSON(SCHEDULE_PATH);
authorizedMembers = (await loadJSON('./config/authorized.json')).map(item => schedule = Array.isArray(rawSchedule) ? rawSchedule : Object.values(rawSchedule || {});
const rawAuthorized = await loadJSON(AUTHORIZED_PATH);
authorizedMembers = (rawAuthorized || []).map(item =>
typeof item === 'string' ? item.toLowerCase() : item typeof item === 'string' ? item.toLowerCase() : item
); );
logger('startup', `Configs loaded. ${authorizedMembers.length} authorized members.`); logger('startup', `Configs loaded. ${authorizedMembers.length} authorized members, ${schedule.length} schedule entries.`);
} catch (err) { } catch (err) {
logger('startup', 'Failed to load one or more config files. Exiting.', 'ERROR'); logger('startup', 'Failed to load one or more config files. Exiting.', 'ERROR');
process.exit(1); process.exit(1);
} }
} }
// Express routes // ====================== EXPRESS ROUTES ======================
app.get('/status', (req, res) => { app.get('/status', (req, res) => {
res.status(200).json({ res.status(200).json({
status: `Alive and kicking. ${authorizedMembers.length} authorized members.` status: `Alive and kicking. ${authorizedMembers.length} authorized members, ${schedule.length} schedule entries.`
}); });
}); });
app.post('/members', async (req, res) => { // ---------- Members ----------
app.get('/members', requireAdmin, (req, res) => {
res.status(200).json(authorizedMembers);
});
app.post('/members', requireAdmin, async (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json.' });
}
const check = validateAuthorized(req.body);
if (!check.ok) {
logger('POST /members', `Rejected: ${check.error}`, 'WARN');
return res.status(400).json({ error: check.error });
}
try { try {
await saveJSON(req.body, './config/authorized.json'); await saveJSON(check.value, AUTHORIZED_PATH);
authorizedMembers = (req.body || []).map(item => authorizedMembers = check.value;
typeof item === 'string' ? item.toLowerCase() : item logger('POST /members', `Authorized users updated (${authorizedMembers.length} entries).`);
); res.status(200).json({ status: 'Authorized users updated.', count: authorizedMembers.length });
logger('POST /members', 'Authorized users updated.');
res.status(200).json({ status: 'Authorized users updated.' });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Failed to update authorized users.' }); res.status(500).json({ error: 'Failed to update authorized users.' });
} }
}); });
// ---------- Schedule ----------
app.get('/schedule', requireAdmin, (req, res) => {
res.status(200).json(schedule);
});
// Replace whole schedule
app.put('/schedule', requireAdmin, async (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json.' });
}
const check = validateScheduleArray(req.body);
if (!check.ok) {
logger('PUT /schedule', `Rejected: ${check.error}`, 'WARN');
return res.status(400).json({ error: check.error });
}
try {
await saveJSON(check.value, SCHEDULE_PATH);
schedule = check.value;
logger('PUT /schedule', `Schedule replaced (${schedule.length} entries).`);
res.status(200).json({ status: 'Schedule replaced.', count: schedule.length });
} catch (err) {
res.status(500).json({ error: 'Failed to save schedule.' });
}
});
// Append a single entry
app.post('/schedule', requireAdmin, async (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json.' });
}
const check = validateScheduleEntry(req.body, schedule.length);
if (!check.ok) {
return res.status(400).json({ error: check.error });
}
if (schedule.length + 1 > MAX_SCHEDULE_ENTRIES) {
return res.status(400).json({ error: `Schedule is full (max ${MAX_SCHEDULE_ENTRIES}).` });
}
const next = [...schedule, req.body];
try {
await saveJSON(next, SCHEDULE_PATH);
schedule = next;
logger('POST /schedule', `Appended "${req.body.month}" (index ${schedule.length - 1}).`);
res.status(201).json({ status: 'Entry appended.', index: schedule.length - 1, count: schedule.length });
} catch (err) {
res.status(500).json({ error: 'Failed to save schedule.' });
}
});
// Replace a single entry
app.put('/schedule/:index', requireAdmin, async (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json.' });
}
const idx = Number.parseInt(req.params.index, 10);
if (!Number.isInteger(idx) || idx < 0 || idx >= schedule.length) {
return res.status(404).json({ error: `Schedule index ${req.params.index} not found.` });
}
const check = validateScheduleEntry(req.body, idx);
if (!check.ok) return res.status(400).json({ error: check.error });
const next = schedule.slice();
next[idx] = req.body;
try {
await saveJSON(next, SCHEDULE_PATH);
schedule = next;
logger('PUT /schedule/:index', `Updated index ${idx} ("${req.body.month}").`);
res.status(200).json({ status: 'Entry updated.', index: idx });
} catch (err) {
res.status(500).json({ error: 'Failed to save schedule.' });
}
});
// Delete a single entry
app.delete('/schedule/:index', requireAdmin, async (req, res) => {
const idx = Number.parseInt(req.params.index, 10);
if (!Number.isInteger(idx) || idx < 0 || idx >= schedule.length) {
return res.status(404).json({ error: `Schedule index ${req.params.index} not found.` });
}
if (schedule.length <= 1) {
return res.status(400).json({ error: 'Refusing to delete the last remaining schedule entry.' });
}
const removed = schedule[idx];
const next = schedule.slice();
next.splice(idx, 1);
try {
await saveJSON(next, SCHEDULE_PATH);
schedule = next;
logger('DELETE /schedule/:index', `Removed index ${idx} ("${removed.month}").`);
res.status(200).json({ status: 'Entry deleted.', count: schedule.length });
} catch (err) {
res.status(500).json({ error: 'Failed to save schedule.' });
}
});
// Preview the exact card text the bot would send for an entry (handy for the admin UI).
app.post('/preview', requireAdmin, async (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json.' });
}
const check = validateScheduleEntry(req.body, 0);
if (!check.ok) return res.status(400).json({ error: check.error });
try {
const preview = await buildCard(req.body);
res.status(200).json(preview);
} catch (err) {
res.status(500).json({ error: `Failed to build preview: ${err.message}` });
}
});
// Admin UI (public HTML shell — all data endpoints still require the bearer token)
app.get('/admin', (req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'admin.html'), (err) => {
if (err) {
logger('GET /admin', `Failed to serve admin.html: ${err.message}`, 'ERROR');
if (!res.headersSent) res.status(500).send('Admin UI unavailable.');
}
});
});
// ====================== WEBEX FRAMEWORK ====================== // ====================== WEBEX FRAMEWORK ======================
// IMPORTANT: We initialize the framework INSIDE main() AFTER loadConfigs() // IMPORTANT: We initialize the framework INSIDE main() AFTER loadConfigs()
@ -399,8 +657,7 @@ async function checkExpiringDiscounts() {
const targetLabel = targetDate.toLocaleDateString('en-US'); const targetLabel = targetDate.toLocaleDateString('en-US');
let covering = null; let covering = null;
for (const key in schedule) { for (const entry of schedule) {
const entry = schedule[key];
const start = Date.parse(entry.start); const start = Date.parse(entry.start);
const end = Date.parse(entry.end); const end = Date.parse(entry.end);
if (!isNaN(start) && !isNaN(end) && targetMs >= start && targetMs <= end) { if (!isNaN(start) && !isNaN(end) && targetMs >= start && targetMs <= end) {
@ -481,6 +738,14 @@ async function main() {
logger('startup', `Bot token injected successfully (${process.env.NODE_ENV || 'development'} mode)`); logger('startup', `Bot token injected successfully (${process.env.NODE_ENV || 'development'} mode)`);
if (!process.env.ADMIN_TOKEN) {
logger('startup',
'ADMIN_TOKEN is not set — /admin, /members, and /schedule endpoints will return 503. ' +
'Add ADMIN_TOKEN=<random> to .env to enable.', 'WARN');
} else {
logger('startup', 'ADMIN_TOKEN detected — admin endpoints enabled.');
}
// Optional: Support webhook URL from env (great for ngrok in dev) // Optional: Support webhook URL from env (great for ngrok in dev)
if (process.env.WEBHOOK_URL) { if (process.env.WEBHOOK_URL) {
config.auth.webex.bot.webhookUrl = process.env.WEBHOOK_URL; config.auth.webex.bot.webhookUrl = process.env.WEBHOOK_URL;
@ -531,8 +796,8 @@ async function main() {
const now = Date.now(); const now = Date.now();
let found = false; let found = false;
for (const key in schedule) { for (let idx = 0; idx < schedule.length; idx++) {
const entry = schedule[key]; const entry = schedule[idx];
const start = Date.parse(entry.start); const start = Date.parse(entry.start);
const end = Date.parse(entry.end); const end = Date.parse(entry.end);
@ -542,7 +807,7 @@ async function main() {
const discount = await buildCard(entry); const discount = await buildCard(entry);
await bot.sendCard(discount.card, discount.text); await bot.sendCard(discount.card, discount.text);
} catch (err) { } catch (err) {
logger('buildCard', `Error for ${key}: ${err.message}`, 'ERROR'); logger('buildCard', `Error for entry ${idx}: ${err.message}`, 'ERROR');
} }
break; // Send only the first matching period break; // Send only the first matching period
} }

654
public/admin.html Normal file
View file

@ -0,0 +1,654 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>DiscountBot Admin</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1f2430;
--border: #2a3040;
--text: #e8ecf3;
--muted: #8a94a7;
--accent: #4f8cff;
--accent-hov: #6ba0ff;
--danger: #ef4b5b;
--success: #35c47a;
--warn: #f0b429;
--dirty: #f0b429;
--code: #a3f0c8;
--shadow: 0 6px 24px rgba(0,0,0,0.4);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f7fb;
--panel: #ffffff;
--panel-2: #f0f2f7;
--border: #dfe3ec;
--text: #1c2330;
--muted: #5b6577;
--accent: #2f6fe0;
--accent-hov: #1f57bc;
--danger: #d0313f;
--success: #1f9d5b;
--warn: #b57408;
--code: #0a6d3e;
--shadow: 0 4px 20px rgba(20,30,60,0.08);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px; line-height: 1.45; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hov); text-decoration: underline; }
header {
position: sticky; top: 0; z-index: 5;
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 12px 20px; display: flex; align-items: center; gap: 16px; box-shadow: var(--shadow);
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: 0.2px; }
header .grow { flex: 1; }
header .tabs { display: flex; gap: 4px; }
header .tabs button {
background: transparent; border: 1px solid transparent; color: var(--muted);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
header .tabs button.active { color: var(--text); background: var(--panel-2); border-color: var(--border); }
header .tabs button:hover { color: var(--text); }
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
section { display: none; }
section.active { display: block; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.toolbar .grow { flex: 1; }
button {
background: var(--accent); color: white; border: none; padding: 8px 14px;
border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;
transition: background 0.15s ease, transform 0.05s ease;
}
button:hover { background: var(--accent-hov); }
button:active { transform: translateY(1px); }
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
button.ghost:hover { background: var(--panel-2); }
button.danger { background: var(--danger); }
button.danger:hover { background: #ff6070; }
button.dirty { background: var(--dirty); color: #1c1c1c; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea, select {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: inherit; width: 100%;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
textarea { resize: vertical; min-height: 60px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 16px; margin-bottom: 16px; box-shadow: var(--shadow);
}
.card.dirty { border-color: var(--dirty); box-shadow: 0 0 0 2px rgba(240,180,41,0.15), var(--shadow); }
.card-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 12px;
padding-bottom: 12px; border-bottom: 1px solid var(--border);
}
.card-header .title { font-size: 15px; font-weight: 600; flex: 1; }
.card-header .meta { color: var(--muted); font-size: 12px; }
.card-header .actions { display: flex; gap: 6px; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600; letter-spacing: 0.3px;
}
.badge.now { background: rgba(53,196,122,0.15); color: var(--success); border: 1px solid var(--success); }
.badge.past { background: rgba(138,148,167,0.15); color: var(--muted); border: 1px solid var(--muted); }
.badge.future { background: rgba(79,140,255,0.15); color: var(--accent); border: 1px solid var(--accent); }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
.grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
fieldset {
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px 12px; margin: 8px 0;
background: var(--panel-2);
}
fieldset > legend { font-size: 12px; font-weight: 600; color: var(--muted); padding: 0 6px;
text-transform: uppercase; letter-spacing: 0.4px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.field label { font-size: 11px; color: var(--muted); font-weight: 500; }
.field input.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--code); }
.row2 { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; }
.row2-code-only { display: grid; grid-template-columns: 1fr; gap: 8px; }
/* Login */
.login-wrap { min-height: 60vh; display: flex; align-items: center; justify-content: center; }
.login {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 28px; width: 100%; max-width: 380px; box-shadow: var(--shadow);
}
.login h2 { margin: 0 0 6px; font-size: 18px; }
.login p { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
.login label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--muted); }
.login .row { display: flex; gap: 8px; margin-top: 12px; }
.login .row button { flex: 1; }
/* Toast */
#toast {
position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 10;
}
.toast {
background: var(--panel); border: 1px solid var(--border); border-left-width: 3px;
padding: 10px 14px; border-radius: 8px; min-width: 220px; max-width: 400px;
box-shadow: var(--shadow); font-size: 13px; animation: slideIn 0.15s ease-out;
}
.toast.error { border-left-color: var(--danger); }
.toast.success { border-left-color: var(--success); }
.toast.info { border-left-color: var(--accent); }
@keyframes slideIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.empty { color: var(--muted); text-align: center; padding: 40px 20px; }
.preview {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 14px; margin-top: 10px; font-size: 13px; white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; max-height: 300px; overflow: auto;
}
.preview-title { font-size: 12px; color: var(--muted); margin: 8px 0 4px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px; }
details { margin-top: 8px; }
details summary { cursor: pointer; color: var(--muted); font-size: 12px; user-select: none; }
details summary:hover { color: var(--text); }
.muted { color: var(--muted); font-size: 12px; }
.hint { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
@media (max-width: 720px) {
.grid { grid-template-columns: 1fr; }
.row2 { grid-template-columns: 1fr; }
header { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div id="login-view" class="login-wrap">
<form id="login-form" class="login" autocomplete="off">
<h2>DiscountBot Admin</h2>
<p>Enter the admin token to manage discount codes and authorized members.</p>
<label for="token">Admin token</label>
<input id="token" type="password" required autocomplete="current-password" />
<label style="margin-top:12px; display:flex; align-items:center; gap:8px; font-size:12px; color: var(--muted);">
<input id="remember" type="checkbox" style="width:auto;" /> Remember for this browser
</label>
<div class="row">
<button type="submit">Sign in</button>
</div>
</form>
</div>
<div id="app-view" style="display:none;">
<header>
<h1>DiscountBot Admin</h1>
<div class="tabs">
<button data-tab="schedule" class="active">Schedule</button>
<button data-tab="members">Members</button>
<button data-tab="status">Status</button>
</div>
<div class="grow"></div>
<span id="server-info" class="muted"></span>
<button class="ghost" id="signout">Sign out</button>
</header>
<main>
<section id="tab-schedule" class="active">
<div class="toolbar">
<div class="grow"></div>
<button id="btn-refresh-schedule" class="ghost">Refresh</button>
<button id="btn-add-blank">Add new month</button>
<button id="btn-add-duplicate" class="ghost">Duplicate latest</button>
</div>
<div class="hint">Click any field to edit. The <b>Save</b> button lights up when changes are pending. All writes are validated and back up the previous file to <code>schedule.json.bak</code>.</div>
<div id="schedule-list"></div>
</section>
<section id="tab-members">
<div class="toolbar">
<div class="grow"></div>
<button id="btn-refresh-members" class="ghost">Refresh</button>
<button id="btn-save-members">Save members</button>
</div>
<div class="hint">One username or email per line. Duplicates and empty lines are removed; everything is lowercased on save.</div>
<textarea id="members-text" rows="20" class="mono"></textarea>
<div class="muted" style="margin-top:8px;">
<span id="members-count">0</span> entries.
</div>
</section>
<section id="tab-status">
<div class="card">
<div class="card-header"><div class="title">Server status</div></div>
<pre id="status-json" class="preview">Loading…</pre>
<div style="margin-top:8px;">
<button class="ghost" id="btn-refresh-status">Refresh</button>
</div>
</div>
<div class="card">
<div class="card-header"><div class="title">About</div></div>
<p class="muted" style="margin:0;">
This page talks to the bot's REST API using the <code>Authorization: Bearer &lt;ADMIN_TOKEN&gt;</code> header.
All schedule/member writes are validated server-side and write atomically with a <code>.bak</code> file
so a bad edit can be rolled back on the host.
</p>
</div>
</section>
</main>
</div>
<div id="toast"></div>
<script>
(() => {
const TOKEN_KEY = 'discountbot_admin_token';
let token = sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY) || '';
let schedule = [];
const dirty = new Set();
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
// ---------- toast ----------
function toast(message, type='info', timeout=3500) {
const box = document.createElement('div');
box.className = `toast ${type}`;
box.textContent = message;
$('#toast').appendChild(box);
setTimeout(() => { box.style.opacity = '0'; box.style.transform = 'translateY(6px)'; box.style.transition = 'opacity .2s, transform .2s'; }, timeout - 200);
setTimeout(() => box.remove(), timeout);
}
// ---------- api ----------
async function api(method, path, body) {
const headers = { 'Authorization': `Bearer ${token}` };
let bodyStr;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
bodyStr = JSON.stringify(body);
}
const res = await fetch(path, { method, headers, body: bodyStr, credentials: 'same-origin' });
const isJson = (res.headers.get('content-type') || '').includes('application/json');
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => '');
if (!res.ok) {
const err = new Error((payload && payload.error) || `HTTP ${res.status}`);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
// ---------- auth flow ----------
function showLogin() {
$('#login-view').style.display = '';
$('#app-view').style.display = 'none';
setTimeout(() => $('#token').focus(), 50);
}
function showApp() {
$('#login-view').style.display = 'none';
$('#app-view').style.display = '';
}
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const t = $('#token').value.trim();
if (!t) return;
token = t;
try {
// Quick check by calling /status (public) + /members (auth) to verify the token
await api('GET', './members');
if ($('#remember').checked) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
sessionStorage.setItem(TOKEN_KEY, token);
$('#token').value = '';
showApp();
loadAll();
} catch (err) {
token = '';
if (err.status === 401) toast('Wrong token.', 'error');
else if (err.status === 503) toast('Server has no ADMIN_TOKEN configured.', 'error', 6000);
else toast(err.message || 'Login failed.', 'error');
}
});
$('#signout').addEventListener('click', () => {
token = '';
sessionStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
schedule = [];
dirty.clear();
showLogin();
});
// ---------- tabs ----------
$$('.tabs button').forEach(btn => {
btn.addEventListener('click', () => {
$$('.tabs button').forEach(b => b.classList.toggle('active', b === btn));
const name = btn.dataset.tab;
$$('main section').forEach(s => s.classList.toggle('active', s.id === `tab-${name}`));
if (name === 'status') loadStatus();
});
});
// ---------- schedule rendering ----------
const FIELDS = [
{ path: ['aeAerie', 'usrow'], label: 'AE / Aerie — US, Canada, ROW' },
{ path: ['aeAerie', 'mexico'], label: 'AE / Aerie — Mexico' },
{ path: ['toddSnyder', 'usrow', 'tstc'], label: 'Todd Snyder — TS Collections' },
{ path: ['toddSnyder', 'usrow', 'thirdparty'], label: 'Todd Snyder — 3rd Party' },
{ path: ['unsubscribed', 'usrow'], label: 'Unsubscribed — Main' },
{ path: ['unsubscribed', 'thirdparty'], label: 'Unsubscribed — 3rd Party' },
{ path: ['unsubscribed', 'giftcard'], label: 'Unsubscribed — Gift Card' },
];
function getAt(obj, p) { return p.reduce((a, k) => (a == null ? a : a[k]), obj); }
function setAt(obj, p, val) {
let cur = obj;
for (let i = 0; i < p.length - 1; i++) {
if (cur[p[i]] == null || typeof cur[p[i]] !== 'object') cur[p[i]] = {};
cur = cur[p[i]];
}
cur[p[p.length - 1]] = val;
}
function periodBadge(entry) {
const now = Date.now();
const s = Date.parse(entry.start);
const e = Date.parse(entry.end);
if (isNaN(s) || isNaN(e)) return { cls: 'past', label: 'invalid dates' };
if (now >= s && now <= e) return { cls: 'now', label: 'ACTIVE NOW' };
if (now < s) return { cls: 'future', label: 'upcoming' };
return { cls: 'past', label: 'past' };
}
function renderSchedule() {
const list = $('#schedule-list');
list.innerHTML = '';
if (!schedule.length) {
list.innerHTML = '<div class="empty">No schedule entries. Click <b>Add new month</b> to create one.</div>';
return;
}
schedule.forEach((entry, idx) => list.appendChild(renderCard(entry, idx)));
}
function renderCard(entry, idx) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.idx = String(idx);
if (dirty.has(idx)) card.classList.add('dirty');
const badge = periodBadge(entry);
const header = document.createElement('div');
header.className = 'card-header';
header.innerHTML = `
<div class="title">
${escapeHTML(entry.month || '(untitled)')}
<span class="badge ${badge.cls}" style="margin-left:8px;">${badge.label}</span>
</div>
<div class="meta">${escapeHTML(entry.start || '?')} → ${escapeHTML(entry.end || '?')}</div>
`;
const actions = document.createElement('div');
actions.className = 'actions';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.className = dirty.has(idx) ? 'dirty' : '';
saveBtn.disabled = !dirty.has(idx);
saveBtn.addEventListener('click', () => saveEntry(idx));
const previewBtn = document.createElement('button');
previewBtn.className = 'ghost';
previewBtn.textContent = 'Preview';
previewBtn.addEventListener('click', () => previewEntry(idx));
const delBtn = document.createElement('button');
delBtn.className = 'ghost';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', () => deleteEntry(idx));
actions.append(saveBtn, previewBtn, delBtn);
header.appendChild(actions);
card.appendChild(header);
// Top row: month/start/end
const topGrid = document.createElement('div');
topGrid.className = 'grid';
topGrid.appendChild(makeInput(idx, ['month'], 'Month label', entry.month || ''));
topGrid.appendChild(makeInput(idx, ['start'], 'Start date', entry.start || '', 'e.g. 8/1/26'));
topGrid.appendChild(makeInput(idx, ['end'], 'End date', entry.end || '', 'e.g. 9/1/26'));
card.appendChild(topGrid);
// Brand fieldsets
FIELDS.forEach(({ path, label }) => {
const fs = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = label;
fs.appendChild(legend);
const codePath = [...path, 'code'];
const msgPath = [...path, 'message'];
const row = document.createElement('div');
row.className = 'row2';
row.appendChild(makeInput(idx, codePath, 'Code', getAt(entry, codePath) || '', '', 'code'));
row.appendChild(makeTextarea(idx, msgPath, 'Message', getAt(entry, msgPath) || ''));
fs.appendChild(row);
card.appendChild(fs);
});
const details = document.createElement('details');
details.innerHTML = `<summary>Preview output</summary><div class="preview-title">Fallback text</div><pre class="preview" data-preview-text>Click Preview to render.</pre>`;
card.appendChild(details);
return card;
}
function makeInput(idx, p, label, value, placeholder='', extraClass='') {
const wrap = document.createElement('div');
wrap.className = 'field';
const lab = document.createElement('label');
lab.textContent = label;
const inp = document.createElement('input');
inp.type = 'text';
inp.value = value == null ? '' : String(value);
if (placeholder) inp.placeholder = placeholder;
if (extraClass) inp.classList.add(extraClass);
inp.addEventListener('input', () => {
setAt(schedule[idx], p, inp.value);
markDirty(idx);
});
wrap.append(lab, inp);
return wrap;
}
function makeTextarea(idx, p, label, value) {
const wrap = document.createElement('div');
wrap.className = 'field';
const lab = document.createElement('label');
lab.textContent = label;
const ta = document.createElement('textarea');
ta.value = value == null ? '' : String(value);
ta.rows = 2;
ta.addEventListener('input', () => {
setAt(schedule[idx], p, ta.value);
markDirty(idx);
});
wrap.append(lab, ta);
return wrap;
}
function markDirty(idx) {
dirty.add(idx);
const card = document.querySelector(`.card[data-idx="${idx}"]`);
if (!card) return;
card.classList.add('dirty');
const btn = card.querySelector('.card-header .actions button:first-child');
if (btn) { btn.classList.add('dirty'); btn.disabled = false; }
}
function clearDirty(idx) {
dirty.delete(idx);
const card = document.querySelector(`.card[data-idx="${idx}"]`);
if (!card) return;
card.classList.remove('dirty');
const btn = card.querySelector('.card-header .actions button:first-child');
if (btn) { btn.classList.remove('dirty'); btn.disabled = true; }
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// ---------- schedule actions ----------
async function saveEntry(idx) {
try {
await api('PUT', `./schedule/${idx}`, schedule[idx]);
clearDirty(idx);
toast(`Saved "${schedule[idx].month}".`, 'success');
} catch (err) {
toast(err.message, 'error', 6000);
}
}
async function deleteEntry(idx) {
if (!confirm(`Delete "${schedule[idx].month}"? A backup is still kept as schedule.json.bak.`)) return;
try {
await api('DELETE', `./schedule/${idx}`);
toast('Entry deleted.', 'success');
loadSchedule();
} catch (err) {
toast(err.message, 'error', 6000);
}
}
async function previewEntry(idx) {
try {
const p = await api('POST', './preview', schedule[idx]);
const card = document.querySelector(`.card[data-idx="${idx}"]`);
const pre = card.querySelector('[data-preview-text]');
pre.textContent = p.text || '(no preview)';
card.querySelector('details').open = true;
} catch (err) {
toast(err.message, 'error', 6000);
}
}
function makeBlankEntry() {
const stub = (code = '', message = '') => ({ code, message });
return {
month: '', start: '', end: '',
aeAerie: { usrow: stub(), mexico: stub() },
toddSnyder: { usrow: { tstc: stub(), thirdparty: stub() } },
unsubscribed: { usrow: stub(), thirdparty: stub(), giftcard: stub() }
};
}
$('#btn-add-blank').addEventListener('click', () => {
schedule.push(makeBlankEntry());
dirty.add(schedule.length - 1);
renderSchedule();
toast('New month added. Fill it in and click Save.', 'info');
document.querySelector(`.card[data-idx="${schedule.length - 1}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
$('#btn-add-duplicate').addEventListener('click', () => {
if (!schedule.length) { toast('Nothing to duplicate.', 'error'); return; }
const last = JSON.parse(JSON.stringify(schedule[schedule.length - 1]));
last.month = last.month + ' (copy)';
schedule.push(last);
dirty.add(schedule.length - 1);
renderSchedule();
toast('Duplicated. Adjust dates + code, then Save.', 'info');
document.querySelector(`.card[data-idx="${schedule.length - 1}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
$('#btn-refresh-schedule').addEventListener('click', () => loadSchedule());
async function loadSchedule() {
try {
const data = await api('GET', './schedule');
schedule = Array.isArray(data) ? data : [];
dirty.clear();
renderSchedule();
} catch (err) {
handleLoadErr(err, 'schedule');
}
}
// ---------- members ----------
async function loadMembers() {
try {
const list = await api('GET', './members');
$('#members-text').value = (list || []).join('\n');
$('#members-count').textContent = String((list || []).length);
} catch (err) {
handleLoadErr(err, 'members');
}
}
$('#btn-refresh-members').addEventListener('click', loadMembers);
$('#members-text').addEventListener('input', () => {
const lines = $('#members-text').value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
$('#members-count').textContent = String(new Set(lines.map(s => s.toLowerCase())).size);
});
$('#btn-save-members').addEventListener('click', async () => {
const raw = $('#members-text').value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (!raw.length) { toast('Refusing to save an empty list.', 'error'); return; }
if (!confirm(`Save ${new Set(raw.map(s => s.toLowerCase())).size} authorized members?`)) return;
try {
const resp = await api('POST', './members', raw);
toast(`Saved ${resp.count} members.`, 'success');
loadMembers();
} catch (err) {
toast(err.message, 'error', 6000);
}
});
// ---------- status ----------
async function loadStatus() {
try {
const r = await fetch('./status');
const j = await r.json();
$('#status-json').textContent = JSON.stringify(j, null, 2);
} catch (err) {
$('#status-json').textContent = `Failed: ${err.message}`;
}
}
$('#btn-refresh-status').addEventListener('click', loadStatus);
// ---------- boot ----------
function handleLoadErr(err, what) {
if (err.status === 401) {
toast('Session expired — please sign in again.', 'error');
$('#signout').click();
} else {
toast(`Failed to load ${what}: ${err.message}`, 'error', 6000);
}
}
async function loadAll() {
try {
const s = await fetch('./status').then(r => r.json());
$('#server-info').textContent = s.status || '';
} catch { /* ignore */ }
await loadSchedule();
await loadMembers();
}
if (token) {
// Verify existing token
api('GET', './members').then(() => { showApp(); loadAll(); })
.catch((err) => {
token = '';
sessionStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
showLogin();
if (err.status && err.status !== 401) toast(err.message, 'error');
});
} else {
showLogin();
}
})();
</script>
</body>
</html>