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(