diff --git a/config/config.json b/config/config.json
index eea3251..14cf6ac 100644
--- a/config/config.json
+++ b/config/config.json
@@ -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": []
}
}
}
diff --git a/html/admin.html b/html/admin.html
new file mode 100644
index 0000000..0ce06e3
--- /dev/null
+++ b/html/admin.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+ CollabCentral
+
+
+
+
+
+
+
+
+
+
You don't have access to this bot
+
Your account isn't on the authorized list for this bot. If you believe this is a mistake, contact the bot's administrator.
+
+
+
+
Admin only
+
You're signed in, but this page is only available to CollabCentral administrators.
+
+
+
+
+
Authorized users
+
People who can compose and send messages as this bot. Add anyone by their Webex email; their name and avatar are pulled from Webex automatically.
+
+
+
+
+
Nobody is authorized yet. Add someone above.
+
+
+
+
+
+
+
+
+
+
diff --git a/html/admin.js b/html/admin.js
new file mode 100644
index 0000000..ba09cde
--- /dev/null
+++ b/html/admin.js
@@ -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);
+}
diff --git a/html/css/app.css b/html/css/app.css
index 5bf81be..a72f7cb 100644
--- a/html/css/app.css
+++ b/html/css/app.css
@@ -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) {
diff --git a/html/js/app.js b/html/js/app.js
index 963b842..1cbd480 100644
--- a/html/js/app.js
+++ b/html/js/app.js
@@ -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() {
diff --git a/index.js b/index.js
index f637b18..e85458c 100644
--- a/index.js
+++ b/index.js
@@ -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);
}
diff --git a/lib/helpers.js b/lib/helpers.js
index 7f93ec6..384fffa 100644
--- a/lib/helpers.js
+++ b/lib/helpers.js
@@ -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.
diff --git a/test/helpers.test.js b/test/helpers.test.js
index 21387b1..ce3166b 100644
--- a/test/helpers.test.js
+++ b/test/helpers.test.js
@@ -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(