From a793c9f39bcdf9fc843850b31b3267215a6dfa86 Mon Sep 17 00:00:00 2001 From: "Joseph B. McQueen" Date: Wed, 1 Jul 2026 20:47:39 -0400 Subject: [PATCH] Add admin UI for managing per-bot authorized users + retract cancelled preview DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 --- config/config.json | 11 +-- html/admin.html | 66 +++++++++++++++ html/admin.js | 173 +++++++++++++++++++++++++++++++++++++ html/css/app.css | 150 ++++++++++++++++++++++++++++++++ html/js/app.js | 70 ++++++++++++++- index.js | 198 ++++++++++++++++++++++++++++++++++++++++++- lib/helpers.js | 11 +++ test/helpers.test.js | 27 ++++++ 8 files changed, 695 insertions(+), 11 deletions(-) create mode 100644 html/admin.html create mode 100644 html/admin.js 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 + + + + + + +
+
+
+
+
Loading…
+
+ +
+
+
+
+ + + + + + + + + + + + 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(