collabcentral/lib/helpers.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

129 lines
5.9 KiB
JavaScript

// Pure helpers extracted from index.js. Everything here is state-free: callers
// pass in whatever piece of config, tokens, or env they want to evaluate
// against. That keeps the functions trivially unit-testable and lets the
// server module stay the single place that owns mutable runtime state.
// Default number of days that completed jobs are retained before the daily
// cleanup cron prunes them. Exported so callers and tests share the constant.
export const COMPLETED_RETENTION_DAYS = 30;
// Composite key for the "draft job being built" bucket. Keying by cookieId +
// appName means a user authorized on multiple bots can build one draft per
// bot without them colliding on top of each other.
export function buildingKey(cookieId, appName) {
return String(cookieId) + ':' + String(appName);
}
// Filters one of the global job arrays (running/scheduled/completed) down to
// the jobs that belong to the requested bot. Null-safe so callers can hand
// in an uninitialized array.
export function jobsForApp(arr, appName) {
return (arr || []).filter(function (j) { return j && j.appName === appName; });
}
// Returns the bot's raw access token from the botTokens map, honoring the
// enabled flag. Returns null when the bot is unknown, explicitly disabled,
// or missing a token. Tolerates a flat "appName -> tokenString" shape too,
// which is what older configs used before the enabled flag was introduced.
export function getBotToken(botTokens, appName) {
if (!botTokens) return null;
var entry = botTokens[appName];
if (!entry) return null;
if (typeof entry === 'string') return entry;
if (entry.enabled === false) return null;
return entry.token || null;
}
export function isBotEnabled(botTokens, appName) {
return getBotToken(botTokens, appName) !== null;
}
// Returns the bot's config block only if the bot is defined in config.json
// AND has an enabled token entry. Returns null otherwise. Every caller should
// go through this instead of reaching into config.webex.bot[...] directly,
// so unknown or disabled bots produce a clean 404 rather than a crash.
export function getBotConfig(config, botTokens, appName) {
if (!appName) return null;
var cfg = config && config.webex && config.webex.bot && config.webex.bot[appName];
if (!cfg) return null;
if (!isBotEnabled(botTokens, appName)) return null;
return cfg;
}
// True iff `personId` is listed under the bot's `authorized` map AND the bot
// is enabled. Missing personId or missing bot config yields false.
export function isAuthorized(config, botTokens, appName, personId) {
var botCfg = getBotConfig(config, botTokens, appName);
if (!botCfg) return false;
if (!personId) return false;
return !!(botCfg.authorized && botCfg.authorized[personId]);
}
// True iff `personId` appears in the top-level config.admins array. Admin
// authority is intentionally cross-bot: a single admin manages authorization
// for every bot the process serves. Missing personId or missing admins list
// yields false so an unconfigured deployment fails closed.
export function isAdmin(config, personId) {
if (!personId) return false;
var admins = config && config.admins;
if (!Array.isArray(admins)) return false;
return admins.indexOf(personId) !== -1;
}
// Replaces the `:app` placeholder in the OAuth callback URL template with the
// appName. Empty template returns an empty string so callers can detect the
// misconfiguration.
export function getOAuthRedirectUri(template, appName) {
return (template || '').replace(':app', appName);
}
// Builds the Webex OAuth authorize URL for a bot. Returns null when the
// required inputs (clientId / template) are missing, so callers can serve an
// actionable 500 instead of a malformed URL.
export function buildAuthUrl({ clientId, template, appName }) {
if (!clientId || !template) return null;
var params = new URLSearchParams();
params.append('client_id', clientId);
params.append('response_type', 'code');
params.append('redirect_uri', getOAuthRedirectUri(template, appName));
params.append('scope', 'spark:kms spark:people_read');
params.append('state', '');
return 'https://webexapis.com/v1/authorize?' + params.toString();
}
// Drops completed jobs older than `retentionDays` from `jobs.completed`.
// Mutates `jobs` in place (matching the pre-extraction behavior) and returns
// a report so callers can log the counts without importing a logger. Falls
// back through endTime → startTime → created for the age comparison, and
// keeps any job that has no timestamp at all as a safety measure.
export function cleanCompletedJobs(jobs, retentionDays = COMPLETED_RETENTION_DAYS, now = Date.now()) {
var cutoffDate = new Date(now - (retentionDays * 24 * 60 * 60 * 1000));
if (!Array.isArray(jobs.completed)) {
return { jobs, removed: 0, cutoff: cutoffDate, retentionDays };
}
var originalCount = jobs.completed.length;
jobs.completed = jobs.completed.filter(function (job) {
var jobDateStr = job.endTime || job.startTime || job.created;
if (!jobDateStr) return true;
return new Date(jobDateStr) >= cutoffDate;
});
return {
jobs,
removed: originalCount - jobs.completed.length,
cutoff: cutoffDate,
retentionDays,
};
}
// Formats a millisecond duration as "Nh Nm N.Ns" (or just "N.Ns" for < 1min,
// or "Nm N.Ns" for < 1hr). Matches the original stat display in job cards.
export function msToTime(duration) {
var milliseconds = parseInt((duration % 1000) / 100)
, seconds = parseInt((duration / 1000) % 60)
, minutes = parseInt((duration / (1000 * 60)) % 60)
, hours = parseInt((duration / (1000 * 60 * 60)) % 24);
if (hours > 0) return hours + "h " + minutes + "m " + seconds + "." + milliseconds + "s";
if (minutes > 0) return minutes + "m " + seconds + "." + milliseconds + "s";
return seconds + "." + milliseconds + "s";
}