Make favorite groups editable from the compose page

Selecting a group in "Additional groups" now auto-saves it to the
caller's favorites, and each favorite gets a × affordance for one-click
removal. Favorites still live where they always have —
config.webex.bot[app].authorized[personId].groups — so nothing changes
for existing installations.

Backend
- New POST /CollabCentral/:app/user/groups/add. Body { id }. Validates
  the caller is authorized for :app, validates :id resolves to a real
  Webex group by looking it up in the cached org-wide group list
  (blocks arbitrary strings from being stuffed into config.json),
  dedups against the existing favorites array, writes config.json via
  saveConfig, and returns the updated array.
- New POST /CollabCentral/:app/user/groups/remove. Body { id }.
  Filters that id out of the caller's favorites, writes only when
  something actually changed (a remove of an unknown id no-ops instead
  of rewriting config.json), and returns the updated array.
- Both endpoints are safe against concurrent writes: index.js is
  single-process and node is single-threaded, so read/mutate/write
  runs atomically per request.
- Both log a compact audit line (last-8 of personId + group name) so
  operators can see who is curating what.

Frontend (sendMessage.html + .js + app.css)
- New "Manage favorites" chip strip renders directly under the
  Favorite Groups picker. Each favorite becomes a pill (uses .alias
  when set, otherwise .name; long labels truncate with ellipsis).
  Clicking × on a pill removes that favorite server-side and updates
  the strip locally.
- Additional Groups picker wires a 'change' listener that diffs the
  current selection against the previous one and only fires
  /user/groups/add for newly-picked ids (never re-fires on the
  reselect side of a deselect+reselect, never spams the API with the
  full selection on every keystroke).
- Client keeps favoriteGroups mirrored to every API response so the
  "already a favorite?" dedup check is a pure in-memory lookup — no
  wasted round trips when the user re-picks a group they already
  favorited.
- Transient status message ("Added to favorites." / "Removed from
  favorites." / error variants) fades under the field label; sticks
  around ~4s.
- New shared styles: .fieldLabelRow (label + inline status),
  .fieldStatus (with --error variant), .chipStrip, .chip,
  .chip__label, .chip__close (with hover/focus states).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph B. McQueen 2026-07-01 20:25:15 -04:00
parent 452673b9be
commit 524b443dce
4 changed files with 320 additions and 4 deletions

View file

@ -234,6 +234,101 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
font-size: 0.82rem; font-size: 0.82rem;
} }
/* Label row: puts the label and an inline transient status message on the
same line without disturbing the vertical rhythm of surrounding fields. */
.fieldLabelRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75em;
margin-bottom: 0.35em;
}
.fieldLabelRow > label {
margin-bottom: 0;
font-weight: 500;
font-size: 0.9rem;
color: var(--text);
}
.fieldStatus {
font-size: 0.8rem;
color: var(--accent, #2563eb);
opacity: 0;
transition: opacity 0.2s;
min-height: 1em;
}
.fieldStatus:not(:empty) {
opacity: 1;
}
.fieldStatus--error {
color: #b91c1c;
}
/* Chip strip: compact list of removable pills used by the "Manage
favorites" affordance below the favorite-groups picker. Chips are
click-target friendly on touch (min 32px tall) and truncate long
labels with ellipsis so a wall of group names doesn't blow out the
card width. */
.chipStrip {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
margin-top: 0.55em;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35em;
max-width: 100%;
padding: 0.3em 0.35em 0.3em 0.7em;
background: var(--surface-muted, #f1f5f9);
border: 1px solid var(--border-strong, #cbd5e1);
border-radius: 999px;
font-size: 0.82rem;
line-height: 1.3;
color: var(--text, #0f172a);
}
.chip__label {
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35em;
height: 1.35em;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted, #64748b);
font-size: 1.05rem;
line-height: 1;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chip__close:hover:not(:disabled),
.chip__close:focus-visible {
background: #fee2e2;
color: #b91c1c;
outline: none;
}
.chip__close:disabled {
opacity: 0.5;
cursor: default;
}
.field-row { .field-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View file

@ -53,9 +53,14 @@
</div> </div>
<div class="field"> <div class="field">
<div class="fieldLabelRow">
<label for="favGroups">Favorite groups</label> <label for="favGroups">Favorite groups</label>
<span id="favGroupsStatus" class="fieldStatus" role="status" aria-live="polite"></span>
</div>
<select id="favGroups" multiple name="favGroups" placeholder="Favorite groups" <select id="favGroups" multiple name="favGroups" placeholder="Favorite groups"
data-silent-initial-value-set="false"></select> data-silent-initial-value-set="false"></select>
<div id="favGroupsManager" class="chipStrip hidden" aria-label="Manage favorite groups"></div>
<span class="hint">Selecting a group under <em>Additional groups</em> saves it to your favorites. Click the × on a chip to remove that favorite.</span>
</div> </div>
<div class="field"> <div class="field">

View file

@ -16,6 +16,12 @@ var message = new EasyMDE({
maxHeight: '260px', maxHeight: '260px',
}); });
// In-memory mirror of the server-side favorites list for this user + bot.
// Kept in sync with every /user/groups/add and /user/groups/remove response
// so the chip strip and the "already a favorite?" check stay authoritative
// without a round-trip.
var favoriteGroups = [];
CollabCentral.init(function (ctx) { CollabCentral.init(function (ctx) {
// User is authenticated and authorized. Reveal the form + populate the // User is authenticated and authorized. Reveal the form + populate the
// group selectors from the backend. // group selectors from the backend.
@ -72,21 +78,142 @@ function showImagePreview(event) {
preview.style.display = 'block'; preview.style.display = 'block';
} }
// ---- Favorites -----------------------------------------------------------
function loadFavoriteGroups(appName) { function loadFavoriteGroups(appName) {
var el = document.getElementById('favGroups'); var el = document.getElementById('favGroups');
fetch('/CollabCentral/' + appName + '/user/groups/list') fetch('/CollabCentral/' + appName + '/user/groups/list')
.then(function (res) { return res.json(); }) .then(function (res) { return res.json(); })
.then(function (groups) { .then(function (groups) {
for (var i = 0; i < groups.length; i++) { favoriteGroups = Array.isArray(groups) ? groups.slice() : [];
for (var i = 0; i < favoriteGroups.length; i++) {
var option = document.createElement('option'); var option = document.createElement('option');
option.text = groups[i].name; option.text = favoriteGroups[i].name;
option.value = groups[i].id; option.value = favoriteGroups[i].id;
el.add(option); el.add(option);
} }
VirtualSelect.init({ ele: '#favGroups' }); VirtualSelect.init({ ele: '#favGroups' });
renderFavoritesManager();
}); });
} }
// Renders the chip strip below the favorites picker. Each chip has an
// × button that removes the group from favorites. The chip strip is
// intentionally separate from the VirtualSelect widget: the picker answers
// "which favorites am I sending to right now?", the chip strip answers
// "which groups do I want to remember for next time?".
function renderFavoritesManager() {
var el = document.getElementById('favGroupsManager');
if (!el) return;
el.innerHTML = '';
if (!favoriteGroups.length) {
el.classList.add('hidden');
return;
}
el.classList.remove('hidden');
for (var i = 0; i < favoriteGroups.length; i++) {
var g = favoriteGroups[i];
var chip = document.createElement('span');
chip.className = 'chip';
var label = document.createElement('span');
label.className = 'chip__label';
label.textContent = g.alias ? g.alias : g.name;
if (g.alias && g.name && g.alias !== g.name) {
chip.title = g.name;
}
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip__close';
btn.setAttribute('aria-label', 'Remove ' + (g.name || 'favorite'));
btn.textContent = '×';
btn.dataset.groupId = g.id;
btn.addEventListener('click', onRemoveFavorite);
chip.appendChild(label);
chip.appendChild(btn);
el.appendChild(chip);
}
}
function onRemoveFavorite(event) {
var groupId = event.currentTarget.dataset.groupId;
if (!groupId) return;
event.currentTarget.disabled = true;
fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: groupId })
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (updatedFavorites) {
favoriteGroups = updatedFavorites || [];
renderFavoritesManager();
flashFavoritesStatus('Removed from favorites.');
})
.catch(function (err) {
console.error('Failed to remove favorite:', err);
flashFavoritesStatus('Could not remove favorite.', true);
event.currentTarget.disabled = false;
});
}
// Fires when the "Additional groups" multi-select changes. For each newly
// selected id that isn't already a favorite we POST /user/groups/add. The
// picker itself keeps the selection (so this send still uses the group);
// the group starts appearing in the Favorite Groups picker on the next
// page load, and immediately in the chip strip below.
function onNewGroupsChange(newlySelectedIds) {
if (!newlySelectedIds || !newlySelectedIds.length) return;
var newGroupsEl = document.getElementById('newGroups');
var pending = 0;
var successes = 0;
newlySelectedIds.forEach(function (id) {
if (favoriteGroups.some(function (g) { return g.id === id; })) return;
pending++;
fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (updatedFavorites) {
favoriteGroups = updatedFavorites || favoriteGroups;
successes++;
renderFavoritesManager();
})
.catch(function (err) {
console.error('Failed to add favorite ' + id + ':', err);
flashFavoritesStatus('Could not save one or more favorites.', true);
})
.finally(function () {
pending--;
if (pending === 0 && successes > 0) {
flashFavoritesStatus(successes === 1
? 'Added to favorites.'
: 'Added ' + successes + ' to favorites.');
}
});
});
}
function flashFavoritesStatus(text, isError) {
var el = document.getElementById('favGroupsStatus');
if (!el) return;
el.textContent = text;
el.classList.toggle('fieldStatus--error', !!isError);
clearTimeout(flashFavoritesStatus._t);
flashFavoritesStatus._t = setTimeout(function () {
el.textContent = '';
el.classList.remove('fieldStatus--error');
}, 4000);
}
function loadNewGroups(appName) { function loadNewGroups(appName) {
var el = document.getElementById('newGroups'); var el = document.getElementById('newGroups');
fetch('/CollabCentral/' + appName + '/user/groups/find') fetch('/CollabCentral/' + appName + '/user/groups/find')
@ -99,9 +226,25 @@ function loadNewGroups(appName) {
el.add(option); el.add(option);
} }
VirtualSelect.init({ ele: '#newGroups' }); VirtualSelect.init({ ele: '#newGroups' });
// VirtualSelect emits a standard 'change' event on the original
// <select>. We diff against the previous selection so we only
// fire /user/groups/add for the newly-picked ids, not the whole
// set on every change.
var previous = [];
el.addEventListener('change', function () {
var current = [];
var selected = el.getSelectedOptions();
for (var j = 0; j < selected.length; j++) current.push(selected[j].value);
var added = current.filter(function (id) { return previous.indexOf(id) === -1; });
previous = current;
if (added.length) onNewGroupsChange(added);
});
}); });
} }
// ---- send-flow modal ------------------------------------------------------
function submit() { function submit() {
var appName = CollabCentral.appName; var appName = CollabCentral.appName;
var scheduled = document.getElementById('scheduledFor').value; var scheduled = document.getElementById('scheduledFor').value;

View file

@ -467,6 +467,79 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
} else { res.status(401) } } else { res.status(401) }
}) })
// Add / remove a favorite group for the calling user. Favorites live inside
// config.json under `webex.bot[app].authorized[personId].groups` so they
// survive restarts and are picklable in the compose page's Favorite Groups
// selector. Both endpoints validate that:
// - the caller is authorized for :app
// - :id looks like a Webex SCIM group id we know about (present in the
// cached org-wide group list) — this stops a bad actor from stuffing
// arbitrary strings into the config.
// Because index.js is single-process and node is single-threaded, concurrent
// requests here can't corrupt config.json — every read/mutate/write executes
// atomically within one handler.
app.post('/CollabCentral/:app/user/groups/add', async function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
var appName = req.params.app;
var personId = req.cookies.id;
var groupId = req.body && req.body.id;
if (!groupId) return res.status(400).send('Missing group id.');
var group;
try {
var allGroups = await getGroupsFromCache();
group = allGroups.find(function (g) { return g.id === groupId; });
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'groups/add cache lookup failed: ' + (err && err.message || err));
return res.status(502).send('Group list unavailable.');
}
if (!group) return res.status(404).send('Unknown group id.');
var favorites = config.webex.bot[appName].authorized[personId].groups || [];
if (favorites.find(function (g) { return g.id === groupId; })) {
return res.status(200).send(favorites);
}
favorites.push({ name: group.displayName, id: group.id });
config.webex.bot[appName].authorized[personId].groups = favorites;
try {
saveConfig(config, './config/config.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'groups/add save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save favorite.');
}
logger('apiEndpoint(' + appName + ')',
'groups/add ' + personId.slice(-8) + ' + "' + group.displayName + '"');
res.status(200).send(favorites);
});
app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
var appName = req.params.app;
var personId = req.cookies.id;
var groupId = req.body && req.body.id;
if (!groupId) return res.status(400).send('Missing group id.');
var favorites = config.webex.bot[appName].authorized[personId].groups || [];
var before = favorites.length;
var removed = favorites.find(function (g) { return g.id === groupId; });
favorites = favorites.filter(function (g) { return g.id !== groupId; });
if (favorites.length === before) return res.status(200).send(favorites);
config.webex.bot[appName].authorized[personId].groups = favorites;
try {
saveConfig(config, './config/config.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'groups/remove save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to remove favorite.');
}
logger('apiEndpoint(' + appName + ')',
'groups/remove ' + personId.slice(-8) + ' - "' + (removed && removed.name || groupId) + '"');
res.status(200).send(favorites);
});
// Options for the session cookies set after a successful OAuth round-trip. // 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 // httpOnly is deliberately false: js/app.js reads `id` (to decide whether to