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>
This commit is contained in:
Joseph B. McQueen 2026-07-01 20:47:39 -04:00
parent 224e853368
commit a793c9f39b
8 changed files with 695 additions and 11 deletions

View file

@ -3,6 +3,9 @@
"name": "CollabCentral",
"port": "1451"
},
"admins": [
"Y2lzY29zcGFyazovL3VzL1BFT1BMRS80NzAyNDlhNC1kNjFjLTQzNmMtYTE1My1kOGUzZTExMmI4MDU"
],
"languages": [],
"webex": {
"bot": {
@ -243,13 +246,7 @@
"displayName": "Joe McQueen",
"email": "mcqueenj@ae.com",
"avatar": "https://avatar-prod-us-east-2.webexcontent.com/Avtr~V1~db9cdc31-8f8e-40b4-916c-8d9e9c92e70d/V1~667e3673b56f4ddc39139c73da9140dd631eb821a756524ead12e37e3a61afc7~9c5b6e767cff46e3af35a003cea38f25~1600",
"groups": [
{
"name": "Group: Audio Visual",
"alias": "AV Team",
"id": "Y2lzY29zcGFyazovL3VzL1NDSU1fR1JPVVAvZTQ3NmY3MjktZWViYi00MDI3LWFlMTctNzc1YWI1ZDgzYmNhOmRiOWNkYzMxLThmOGUtNDBiNC05MTZjLThkOWU5YzkyZTcwZA"
}
]
"groups": []
}
}
}

66
html/admin.html Normal file
View file

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CollabCentral</title>
<link id="appFavicon" rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="css/app.css">
</head>
<body data-page="admin" data-title-suffix="Admin">
<header class="appHeader">
<div class="appHeader__inner">
<div class="appHeader__brand">
<div id="appAvatar" class="appHeader__avatar"></div>
<div id="appLabel" class="appHeader__label">Loading…</div>
</div>
<nav class="appHeader__nav">
<a href="./sendMessage.html" data-nav="send">Send Message</a>
<a href="./monitorJobs.html" data-nav="monitor">Monitor Jobs</a>
</nav>
<div class="appHeader__spacer"></div>
<div id="appUser" class="appHeader__user"></div>
</div>
</header>
<div id="unauthorizedPanel" class="panel hidden">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list for this bot. If you believe this is a mistake, contact the bot's administrator.</p>
</div>
<div id="notAdminPanel" class="panel hidden">
<h2>Admin only</h2>
<p>You're signed in, but this page is only available to CollabCentral administrators.</p>
</div>
<div id="adminPanel" class="container hidden">
<div class="card">
<h1 class="pageTitle">Authorized users</h1>
<p class="pageSubtitle">People who can compose and send messages as <span id="botLabelInline">this bot</span>. Add anyone by their Webex email; their name and avatar are pulled from Webex automatically.</p>
<form id="addUserForm" class="addUserForm">
<div class="field">
<label for="newUserEmail">Add a user by email</label>
<div class="inlineFieldRow">
<input type="email" id="newUserEmail" required autocomplete="off" placeholder="someone@example.com">
<button type="submit" class="btn btn-primary">Add user</button>
</div>
<span id="addUserStatus" class="fieldStatus" role="status" aria-live="polite"></span>
</div>
</form>
<div id="userListWrapper" class="userList">
<div id="userListEmpty" class="hint hidden">Nobody is authorized yet. Add someone above.</div>
<ul id="userList" class="userList__items"></ul>
</div>
</div>
</div>
<script src="js/app.js"></script>
<script src="admin.js"></script>
</body>
</html>

173
html/admin.js Normal file
View file

@ -0,0 +1,173 @@
// Page script for admin.html. Bootstrapping (auth cookie check, /info fetch,
// header rendering, active-nav highlight) lives in js/app.js; this file wires
// the "add user" form and the removable user list, and swaps between the
// unauthorized / not-admin / admin panels depending on the /info payload.
CollabCentral.init(function (ctx) {
if (!ctx.info.isAdmin) {
document.getElementById('notAdminPanel').classList.remove('hidden');
return;
}
document.getElementById('adminPanel').classList.remove('hidden');
var labelEl = document.getElementById('botLabelInline');
if (labelEl && ctx.info.label) labelEl.textContent = ctx.info.label;
loadUsers();
});
function loadUsers() {
fetch('/CollabCentral/' + CollabCentral.appName + '/admin/users')
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(renderUsers)
.catch(function (err) {
console.error('Failed to load users:', err);
flashAddStatus('Could not load the user list.', true);
});
}
function renderUsers(users) {
var listEl = document.getElementById('userList');
var emptyEl = document.getElementById('userListEmpty');
if (!listEl) return;
listEl.innerHTML = '';
if (!users || !users.length) {
emptyEl.classList.remove('hidden');
return;
}
emptyEl.classList.add('hidden');
users
.slice()
.sort(function (a, b) {
return String(a.displayName || '').localeCompare(String(b.displayName || ''));
})
.forEach(function (u) { listEl.appendChild(userRow(u)); });
}
function userRow(user) {
var li = document.createElement('li');
li.className = 'userRow';
var avatar = document.createElement('div');
avatar.className = 'userRow__avatar';
if (user.avatar) {
avatar.style.backgroundImage = 'url(' + JSON.stringify(user.avatar) + ')';
} else {
avatar.textContent = (user.displayName || '?').slice(0, 1).toUpperCase();
avatar.classList.add('userRow__avatar--initials');
}
li.appendChild(avatar);
var meta = document.createElement('div');
meta.className = 'userRow__meta';
var name = document.createElement('div');
name.className = 'userRow__name';
name.textContent = user.displayName || '(no display name)';
var email = document.createElement('div');
email.className = 'userRow__email';
email.textContent = user.email || '';
var stats = document.createElement('div');
stats.className = 'userRow__stats';
stats.textContent = (user.groupCount === 1)
? '1 favorite group'
: (user.groupCount || 0) + ' favorite groups';
meta.appendChild(name);
meta.appendChild(email);
meta.appendChild(stats);
li.appendChild(meta);
var actions = document.createElement('div');
actions.className = 'userRow__actions';
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger';
removeBtn.textContent = 'Remove';
removeBtn.dataset.userId = user.id;
removeBtn.dataset.userName = user.displayName || '';
removeBtn.addEventListener('click', onRemoveUser);
actions.appendChild(removeBtn);
li.appendChild(actions);
return li;
}
function onRemoveUser(event) {
var btn = event.currentTarget;
var userId = btn.dataset.userId;
var userName = btn.dataset.userName || 'this user';
if (!userId) return;
if (!window.confirm('Remove ' + userName + ' from this bot?')) return;
btn.disabled = true;
fetch('/CollabCentral/' + CollabCentral.appName + '/admin/users/' + encodeURIComponent(userId), {
method: 'DELETE'
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (body) {
renderUsers(body.users || []);
flashAddStatus('Removed ' + userName + '.');
})
.catch(function (err) {
console.error('Failed to remove user:', err);
btn.disabled = false;
flashAddStatus('Could not remove ' + userName + '.', true);
});
}
document.getElementById('addUserForm').addEventListener('submit', function (event) {
event.preventDefault();
var input = document.getElementById('newUserEmail');
var email = (input.value || '').trim();
if (!email) return;
var submitBtn = event.target.querySelector('button[type="submit"]');
submitBtn.disabled = true;
fetch('/CollabCentral/' + CollabCentral.appName + '/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function (res) {
return res.json().then(function (body) { return { ok: res.ok, status: res.status, body: body }; })
.catch(function () { return { ok: res.ok, status: res.status, body: null }; });
})
.then(function (result) {
if (result.ok) {
renderUsers((result.body && result.body.users) || []);
var added = result.body && result.body.user;
flashAddStatus(added
? 'Added ' + (added.displayName || added.email) + '.'
: 'User added.');
input.value = '';
} else if (result.status === 404) {
flashAddStatus('No Webex user found for that email.', true);
} else if (result.status === 403) {
flashAddStatus('You are not an administrator.', true);
} else if (result.status === 502) {
flashAddStatus('Could not reach the Webex directory. Try again.', true);
} else {
flashAddStatus('Something went wrong. Please try again.', true);
}
})
.catch(function (err) {
console.error('Failed to add user:', err);
flashAddStatus('Network error while adding user.', true);
})
.finally(function () {
submitBtn.disabled = false;
});
});
function flashAddStatus(text, isError) {
var el = document.getElementById('addUserStatus');
if (!el) return;
el.textContent = text;
el.classList.toggle('fieldStatus--error', !!isError);
clearTimeout(flashAddStatus._t);
flashAddStatus._t = setTimeout(function () {
el.textContent = '';
el.classList.remove('fieldStatus--error');
}, 5000);
}

View file

@ -136,6 +136,73 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
max-width: 20ch;
}
/* When the user chip is a menu (admin case), it becomes a positioned
dropdown container. Non-menu chips keep the plain-text ellipsis look. */
.appHeader__user--menu {
position: relative;
overflow: visible;
max-width: none;
}
.appHeader__userBtn {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.3em 0.6em;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.appHeader__userBtn:hover,
.appHeader__userBtn[aria-expanded="true"] {
background: var(--bg);
border-color: var(--border-strong);
color: var(--text);
}
.appHeader__userChevron {
font-size: 0.75em;
line-height: 1;
color: inherit;
opacity: 0.7;
}
.appHeader__userMenu {
position: absolute;
top: calc(100% + 0.35em);
right: 0;
z-index: 30;
min-width: 12em;
margin: 0;
padding: 0.35em 0;
list-style: none;
background: var(--card);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.appHeader__userMenu li { margin: 0; }
.appHeader__userMenu a {
display: block;
padding: 0.5em 0.9em;
color: var(--text);
text-decoration: none;
font-size: 0.9rem;
}
.appHeader__userMenu a:hover,
.appHeader__userMenu a:focus-visible {
background: var(--bg);
outline: none;
}
/* ------- Page container / cards ------------------------------------------ */
.container {
@ -618,6 +685,89 @@ table.dataTable.no-footer { border-bottom: none; }
margin-right: auto;
}
/* ------- Admin page: user list + add-user form -------------------------- */
/* One-row form with input + button laid out side-by-side. Wraps on
narrow screens rather than overflowing the card. */
.inlineFieldRow {
display: flex;
gap: 0.6em;
align-items: stretch;
flex-wrap: wrap;
}
.inlineFieldRow input[type="email"],
.inlineFieldRow input[type="text"] {
flex: 1 1 20ch;
min-width: 12ch;
}
.inlineFieldRow .btn {
flex: 0 0 auto;
}
.userList {
margin-top: 1.2em;
}
.userList__items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.userRow {
display: flex;
align-items: center;
gap: 0.9em;
padding: 0.6em 0.75em;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
}
.userRow__avatar {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg) center / cover no-repeat;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-weight: 600;
font-size: 1rem;
}
.userRow__avatar--initials {
background: var(--surface-muted, #f1f5f9);
color: var(--text);
}
.userRow__meta {
flex: 1 1 auto;
min-width: 0;
}
.userRow__name {
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.userRow__email {
font-size: 0.85rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.userRow__stats {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 0.15em;
}
.userRow__actions {
flex: 0 0 auto;
}
/* ------- Responsive ------------------------------------------------------ */
@media (max-width: 640px) {

View file

@ -87,9 +87,75 @@
if (fav) fav.href = info.faviconUrl;
}
// User (from cookie set at OAuth completion)
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) userEl.textContent = getCookie('displayName') || '';
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() {

198
index.js
View file

@ -312,7 +312,8 @@ app.get('/CollabCentral/:app/info', function (req, res) {
iconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.png',
faviconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.ico',
avatarUrl: profile.avatar || null,
authorized: isAuthorized(appName, personId)
authorized: isAuthorized(appName, personId),
isAdmin: isAdmin(personId)
});
});
@ -330,6 +331,12 @@ app.post('/CollabCentral/:app/jobs/:action', (req, res) => {
"markdown": result.markdown,
"html": result.html
}
// Remember the preview DM's Webex message id so
// /jobs/cancel can retract it and /jobs/runNow can
// treat it as "already delivered" (future).
if (result && result.id) {
jobs.building[buildingKey(req)].previewMessageId = result.id;
}
await buildTranslations(jobs.building[buildingKey(req)].message.english)
.then(result => {
@ -365,10 +372,18 @@ app.post('/CollabCentral/:app/jobs/:action', (req, res) => {
// or not a building entry actually existed for (personId, app),
// so the client can always fire this on "cancel" without needing
// to check state first.
//
// If the building job has a previewMessageId (captured during
// /jobs/edit), also retract that DM from the sender's own space
// so the review message doesn't hang around after they discarded
// the draft. Best-effort — a failed Webex delete is logged but
// does not fail the cancel.
logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel");
var key = buildingKey(req);
var hadJob = !!jobs.building[key];
var job = jobs.building[key];
var hadJob = !!job;
if (hadJob) {
var previewId = job.previewMessageId;
delete jobs.building[key];
try {
saveConfig(jobs, './config/jobs.json');
@ -377,6 +392,12 @@ app.post('/CollabCentral/:app/jobs/:action', (req, res) => {
'jobs/cancel save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to cancel job.');
}
if (previewId) {
// Fire-and-forget; we've already committed the cancel
// to disk and we don't want a slow Webex round trip to
// hold the response.
deleteWebexMessage(previewId, req.params.app);
}
}
return res.status(200).send({ cancelled: hadJob });
} else { res.status(404) }
@ -563,6 +584,111 @@ app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
res.status(200).send(favorites);
});
// ---- Admin: manage the authorized-user list for a bot ---------------------
//
// Admin authority lives in a top-level config.admins array (personIds).
// Every admin route rejects with 403 for non-admin callers — 403 rather than
// 401 so the client can tell an admin-only route apart from a signed-out
// state (which would 401). Admin actions are cross-bot in concept but the
// routes are still :app-scoped because the resource being edited is per-bot
// (authorized users on THAT bot) and it keeps the OAuth session model
// unchanged (admin uses the same session cookie as any other page).
//
// requireAdmin returns null on success or an Express-response-sending
// function on failure, so each handler stays a straight-line function.
function requireAdmin(req, res) {
if (!req.cookies || !req.cookies.id) return res.status(401).send('Unauthorized.');
if (!isAdmin(req.cookies.id)) return res.status(403).send('Admin only.');
return null;
}
// Shape of a single row returned by the admin users endpoints. Kept in one
// helper so add / delete / list all produce identical output — the client
// can treat every response as an authoritative "here's the current state".
function adminUserRow(entry) {
var groups = Array.isArray(entry && entry.groups) ? entry.groups : [];
return {
id: entry.id,
displayName: entry.displayName || '',
email: entry.email || '',
avatar: entry.avatar || null,
groupCount: groups.length
};
}
function adminUsersList(appName) {
var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {};
return Object.keys(authorized).map(function (id) { return adminUserRow(authorized[id]); });
}
app.get('/CollabCentral/:app/admin/users', function (req, res) {
if (requireAdmin(req, res)) return;
logger('apiEndpoint(' + req.params.app + ')', 'GET /admin/users');
res.status(200).send(adminUsersList(req.params.app));
});
app.post('/CollabCentral/:app/admin/users', async function (req, res) {
if (requireAdmin(req, res)) return;
var appName = req.params.app;
var email = req.body && req.body.email && String(req.body.email).trim();
if (!email) return res.status(400).send('Missing email.');
var person;
try {
person = await findPersonByEmail(email);
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users lookup failed for "' + email + '": ' + (err && err.message || err));
return res.status(502).send('Failed to reach Webex directory.');
}
if (!person) return res.status(404).send('No Webex user found for that email.');
var authorized = config.webex.bot[appName].authorized = config.webex.bot[appName].authorized || {};
if (!authorized[person.id]) {
authorized[person.id] = {
id: person.id,
displayName: person.displayName,
email: person.email,
avatar: person.avatar,
groups: []
};
try {
saveConfig(config, './config/config.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save.');
}
logger('apiEndpoint(' + appName + ')',
'admin/users + "' + person.displayName + '" <' + person.email + '>');
}
res.status(200).send({
user: adminUserRow(authorized[person.id]),
users: adminUsersList(appName)
});
});
app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) {
if (requireAdmin(req, res)) return;
var appName = req.params.app;
var targetId = req.params.id;
var authorized = (config.webex.bot[appName] && config.webex.bot[appName].authorized) || {};
if (!authorized[targetId]) {
return res.status(200).send({ removed: false, users: adminUsersList(appName) });
}
var name = authorized[targetId].displayName || targetId;
delete authorized[targetId];
try {
saveConfig(config, './config/config.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users delete save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save.');
}
logger('apiEndpoint(' + appName + ')', 'admin/users - "' + name + '"');
res.status(200).send({ removed: true, users: adminUsersList(appName) });
});
// Options for the session cookies set after a successful OAuth round-trip.
//
// httpOnly is deliberately false: js/app.js reads `id` (to decide whether to
@ -1235,6 +1361,70 @@ async function fetchAndRetryIfNecessary(callAPIFn) {
return response
}
// Look up a Webex person by email via /v1/people. Used by the admin UI so
// authorizing someone on a bot only requires their email — the id, display
// name, and avatar come from Webex. Resolves to null when the email doesn't
// match any account or when the Webex request fails; the caller distinguishes
// with its own 404/502 as appropriate.
function findPersonByEmail(email) {
return new Promise(function (resolve, reject) {
var url = 'https://webexapis.com/v1/people?email=' + encodeURIComponent(email);
var requestOptions = {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + getServiceAccountAccessToken(),
'Content-Type': 'application/json'
}
};
fetchWithRateLimit(url, requestOptions)
.then(function (response) {
if (!response.ok) {
return reject(new Error('HTTP ' + response.status + ' from Webex /people'));
}
return response.json();
})
.then(function (body) {
var items = body && body.items;
if (!items || !items.length) return resolve(null);
var person = items[0];
resolve({
id: person.id,
displayName: person.displayName,
email: (person.emails && person.emails[0]) || email,
avatar: person.avatar || null
});
})
.catch(reject);
});
}
// Best-effort DELETE on a Webex message we previously sent (right now used
// only to retract the preview DM when the user cancels a draft). Never
// throws: the caller doesn't want a Webex hiccup here to bounce the whole
// cancel flow, so we swallow + log and let the caller move on.
function deleteWebexMessage(messageId, appName) {
return new Promise(function (resolve) {
var url = "https://webexapis.com/v1/messages/" + encodeURIComponent(messageId);
var requestOptions = {
method: 'DELETE',
headers: { "Authorization": "Bearer " + getBotToken(appName) }
};
fetchWithRateLimit(url, requestOptions)
.then(function (response) {
if (!response.ok) {
logger('deleteWebexMessage',
'HTTP ' + response.status + ' for ' + messageId);
}
resolve(response.ok);
})
.catch(function (err) {
logger('deleteWebexMessage',
'error for ' + messageId + ': ' + (err && err.message || err));
resolve(false);
});
});
}
function sendDirectMessage(toPersonId, message, imageName, appName) {
return new Promise(async function (resolve, reject) {
@ -1430,6 +1620,10 @@ function isAuthorized(appName, personId) {
return helpers.isAuthorized(config, botTokens, appName, personId);
}
function isAdmin(personId) {
return helpers.isAdmin(config, personId);
}
function msToTime(duration) {
return helpers.msToTime(duration);
}

View file

@ -59,6 +59,17 @@ export function isAuthorized(config, botTokens, appName, personId) {
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.

View file

@ -7,6 +7,7 @@ import {
isBotEnabled,
getBotConfig,
isAuthorized,
isAdmin,
getOAuthRedirectUri,
buildAuthUrl,
cleanCompletedJobs,
@ -189,6 +190,32 @@ describe('isAuthorized', () => {
});
});
describe('isAdmin', () => {
test('true when the person appears in config.admins', () => {
assert.equal(isAdmin({ admins: ['personA', 'personB'] }, 'personA'), true);
});
test('false when the person is not in config.admins', () => {
assert.equal(isAdmin({ admins: ['personA'] }, 'stranger'), false);
});
test('false when personId is missing', () => {
assert.equal(isAdmin({ admins: ['personA'] }, undefined), false);
assert.equal(isAdmin({ admins: ['personA'] }, ''), false);
});
test('false (fails closed) when admins is missing or not an array', () => {
assert.equal(isAdmin({}, 'personA'), false);
assert.equal(isAdmin({ admins: null }, 'personA'), false);
assert.equal(isAdmin({ admins: 'personA' }, 'personA'), false);
});
test('false on missing config entirely', () => {
assert.equal(isAdmin(undefined, 'personA'), false);
assert.equal(isAdmin(null, 'personA'), false);
});
});
describe('getOAuthRedirectUri', () => {
test('replaces the :app placeholder with the appName', () => {
assert.equal(