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>
129 lines
5.9 KiB
JavaScript
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";
|
|
}
|