collabcentral/html/js/app.js
Joseph B. McQueen a793c9f39b Add admin UI for managing per-bot authorized users + retract cancelled preview DMs
Two features stitched together because they touch the same building-
job data path.

--- 1. Retract preview DM on cancel -----------------------------------

The /jobs/edit flow DMs a preview of the composed message to the
sender's own Webex space. Until now that DM lingered even if the
sender then hit Cancel or Discard.

- /jobs/edit now stores the returned message id as
  jobs.building[key].previewMessageId.
- /jobs/cancel captures that id before deleting the building entry,
  saves the cancel first, then fires a best-effort
  DELETE /v1/messages/<id> against Webex.
- New deleteWebexMessage(messageId, appName) helper wraps the DELETE.
  Uses the bot token (bots own their messages) and never throws — a
  Webex hiccup logs but doesn't fail the cancel that already
  succeeded on our side. Called fire-and-forget so the HTTP response
  isn't blocked on a slow Webex round trip.

--- 2. Admin: manage authorized users -------------------------------

Admin authority lives in a new top-level config.admins array of
personIds (seeded with Joe's id). Admin actions are cross-bot in
concept but the routes are :app-scoped because the resource being
edited is per-bot and it lets admin reuse the existing OAuth session
without a separate auth surface.

Helpers
- lib/helpers.js: new pure isAdmin(config, personId) that fails
  closed when admins is missing, not-an-array, or config is null.
- test/helpers.test.js: 5 new assertions covering the happy path,
  the "not in list" case, missing personId, non-array admins, and
  missing config. Total suite is now 48 assertions across 11 groups.

Server-side (index.js)
- New findPersonByEmail(email) helper hits Webex /v1/people?email=
  using the service account token, returns
  { id, displayName, email, avatar } or null.
- /info now returns isAdmin so the client can decide whether to
  render the admin dropdown.
- New requireAdmin(req, res) gate returns 401 for signed-out and
  403 for signed-in-but-not-admin (distinct codes so the frontend
  can render distinct panels).
- GET  /CollabCentral/:app/admin/users            → list users
- POST /CollabCentral/:app/admin/users            → lookup + add
- DELETE /CollabCentral/:app/admin/users/:id      → remove
- Shared adminUserRow / adminUsersList shape so every response is an
  authoritative snapshot the client can render without merging.
- DELETE of an unknown id is idempotent — returns 200 removed:false
  without rewriting config.json.

Frontend
- New html/admin.html + html/admin.js on the shared layout. Panels
  swap between not-signed-in / not-admin / admin. Add-user form
  takes an email; user list renders as rows with Webex avatar
  (fallback initials), name, email, favorite-group count, and a
  Remove button that confirm()s before firing DELETE.
- html/js/app.js: renderUserChip() replaces the plain-text top-right
  user label with a proper button + dropdown menu when the caller
  is an admin. Menu is keyboard-friendly (Escape to close), closes
  on outside-click, and currently exposes one item ("Admin" →
  admin.html). Non-admins get the plain-text label unchanged, so
  the existing pages are visually identical for them.
- html/css/app.css: new .appHeader__userBtn / .appHeader__userMenu
  dropdown, .inlineFieldRow for the email-plus-button pattern, and
  .userRow* rules for the admin user list.

Config
- Add config.admins array seeded with Joe McQueen's personId.
- config.json also picks up an in-app state change from the running
  instance (an "AV Team" favorite removed from Joe's techupdates
  authorized entry via the favorites UI). Rolling that into this
  commit so the file stops drifting from origin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:47:39 -04:00

225 lines
8.2 KiB
JavaScript

// Shared browser bootstrap for the three CollabCentral pages (sendMessage,
// monitorJobs, jobDetail). Handles the auth cookie check, redirects to the
// OAuth flow when needed, fetches /info once, and populates the shared header
// (bot avatar, bot label, current user name, active-page highlight).
//
// Pages should:
// 1. Include /CollabCentral/<app>/js/app.js *before* their page script.
// 2. Set `<body data-page="send|monitor|detail">` so the header highlights
// the correct nav link.
// 3. Call `CollabCentral.init(onReady)` after DOMContentLoaded (or via a
// <script defer> at the bottom of <body>). `onReady({ info, appName })`
// fires once the user is authenticated AND authorized; otherwise the
// shared unauthorized panel is shown and the callback is skipped.
(function () {
var pathArray = window.location.pathname.split('/');
// Path shape: /CollabCentral/<appName>/<file>
var appName = pathArray[2];
// ---- helpers exposed on window.CollabCentral ---------------------------
function getCookie(name) {
var target = name + '=';
var parts = decodeURIComponent(document.cookie || '').split(';');
for (var i = 0; i < parts.length; i++) {
var c = parts[i].trim();
if (c.indexOf(target) === 0) return c.substring(target.length);
}
return '';
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDate(input) {
if (!input) return '';
try {
var d = new Date(input);
if (isNaN(d.getTime())) return String(input);
return d.toLocaleString();
} catch (e) {
return String(input);
}
}
// ---- header rendering --------------------------------------------------
function highlightActiveNav() {
var page = (document.body && document.body.getAttribute('data-page')) || '';
if (!page) return;
var links = document.querySelectorAll('.appHeader__nav a[data-nav]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-nav') === page) {
links[i].setAttribute('aria-current', 'page');
} else {
links[i].removeAttribute('aria-current');
}
}
}
function renderHeader(info) {
// Bot label + document title
if (info.label) {
var labelEl = document.getElementById('appLabel');
if (labelEl) labelEl.textContent = info.label;
var titleSuffix = (document.body && document.body.getAttribute('data-title-suffix')) || '';
document.title = titleSuffix ? info.label + ' — ' + titleSuffix : info.label;
}
// Bot avatar
var avatarUrl = info.avatarUrl || info.iconUrl;
if (avatarUrl) {
var avatarEl = document.getElementById('appAvatar');
if (avatarEl) avatarEl.style.backgroundImage = 'url(' + JSON.stringify(avatarUrl) + ')';
}
// Favicon
if (info.faviconUrl) {
var fav = document.getElementById('appFavicon');
if (fav) fav.href = info.faviconUrl;
}
renderUserChip(info);
}
// Renders the top-right user affordance. Non-admins get a plain text
// chip; admins get a button that toggles a small menu with an "Admin"
// link. The menu is intentionally minimal for now — the same pattern
// scales cleanly if we add sign-out or per-bot switchers later.
function renderUserChip(info) {
var userEl = document.getElementById('appUser');
if (!userEl) return;
var displayName = getCookie('displayName') || '';
// Non-admins: plain text, same as before.
if (!info.isAdmin) {
userEl.textContent = displayName;
userEl.classList.remove('appHeader__user--menu');
return;
}
// Admins: button + hidden menu.
userEl.classList.add('appHeader__user--menu');
userEl.innerHTML = '';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'appHeader__userBtn';
btn.setAttribute('aria-haspopup', 'menu');
btn.setAttribute('aria-expanded', 'false');
var nameSpan = document.createElement('span');
nameSpan.textContent = displayName;
var chevron = document.createElement('span');
chevron.className = 'appHeader__userChevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '▾';
btn.appendChild(nameSpan);
btn.appendChild(chevron);
var menu = document.createElement('ul');
menu.className = 'appHeader__userMenu hidden';
menu.setAttribute('role', 'menu');
var adminItem = document.createElement('li');
var adminLink = document.createElement('a');
adminLink.href = './admin.html';
adminLink.setAttribute('role', 'menuitem');
adminLink.textContent = 'Admin';
adminItem.appendChild(adminLink);
menu.appendChild(adminItem);
userEl.appendChild(btn);
userEl.appendChild(menu);
function closeMenu() {
menu.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
}
function toggleMenu(e) {
e.stopPropagation();
var open = !menu.classList.toggle('hidden');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
btn.addEventListener('click', toggleMenu);
document.addEventListener('click', function (e) {
if (menu.classList.contains('hidden')) return;
if (userEl.contains(e.target)) return;
closeMenu();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.classList.contains('hidden')) closeMenu();
});
}
function showUnauthorized() {
// Every page includes an #unauthorizedPanel in its markup for this
// exact fallback. Reveal it and hide anything else the page might
// have already shown.
var el = document.getElementById('unauthorizedPanel');
if (el) el.classList.remove('hidden');
}
// ---- redirect to OAuth when no session cookie is present ---------------
function redirectToOAuth() {
// Preserve the pre-existing behavior of appending a random query
// suffix so the browser does not cache the intermediate redirect.
var randomTail = Math.random().toString().substring(2);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(function (res) { return res.text(); })
.then(function (url) {
if (!url) return;
window.location.href = url + randomTail;
})
.catch(function (err) {
console.error('Failed to fetch /authUrl:', err);
});
}
// ---- top-level init ----------------------------------------------------
function init(onReady) {
highlightActiveNav();
if (!getCookie('id')) {
redirectToOAuth();
return;
}
fetch('/CollabCentral/' + appName + '/info')
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (info) {
renderHeader(info);
if (!info.authorized) {
showUnauthorized();
return;
}
if (typeof onReady === 'function') {
onReady({ info: info, appName: appName });
}
})
.catch(function (err) {
console.error('Failed to load /info:', err);
});
}
// ---- expose ------------------------------------------------------------
window.CollabCentral = {
appName: appName,
init: init,
getCookie: getCookie,
escapeHtml: escapeHtml,
formatDate: formatDate,
};
})();