fix(admin/users): explain empty group dropdown instead of silent placeholder
The 'Add to group' dropdown on /admin/users/{id} silently filtered out
every Google-Workspace-managed group (rightly — the API would 409 on
POST). On deployments where Admin and Everyone are both Workspace-mapped
via AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL and no custom Agnes groups exist
yet (FoundryAI prod + dev today), the picker showed only the literal
'— Pick a group —' option with the 'Add' button disabled. Operator had
no indication that they needed to create a custom group first.
Three states surface a hint below the picker now:
- user is already in every group (literally nothing left)
- every remaining group is Google-Workspace-managed (link to
/admin/groups + admin.google.com explainer)
- no groups exist at all
The skip-google-managed logic stays — POST would still 409 on those
rows, this just stops the empty-state from being a silent dead end.
This commit is contained in:
parent
cbf335cb5e
commit
438ac78905
2 changed files with 61 additions and 4 deletions
|
|
@ -10,6 +10,11 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/admin/users/{id}` — "Add to group" dropdown explains itself when empty instead of leaving the admin staring at a silent `— Pick a group —` placeholder. Three cases now surface a hint below the picker: (a) user is already in every group, (b) every remaining group is Google-Workspace-managed and Agnes can't grant manually (POST would 409 — link to `/admin/groups` to create a custom group), (c) no groups exist at all. Pre-fix on deployments where `Admin` + `Everyone` are mapped via `AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL` and no custom groups exist, the picker was empty with zero indication that the operator needed to create a custom group first.
|
||||
- `/admin/users/{id}` — "Add to group" dropdown's `loadAll()` race fixed: pre-fix `loadGroups()` and `loadMemberships()` ran in parallel and `refreshGroupDropdown()` (called from `loadGroups`) read the `memberships` global, which could still be `[]` if memberships hadn't returned yet — letting the dropdown show groups the user was already in. `loadMemberships()` now re-runs the dropdown refresh once it has its data, so the final render reflects both data sets regardless of which fetch completes first.
|
||||
|
||||
## [0.44.0] — 2026-05-07
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -126,6 +126,17 @@
|
|||
border: 1px solid var(--primary, #6366f1); cursor: pointer;
|
||||
}
|
||||
.add-member-row button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ud-hint {
|
||||
margin: 8px 0 0; font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
.ud-hint a { color: var(--accent, #2563eb); }
|
||||
.ud-hint code {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
background: var(--border-light, #f3f4f6);
|
||||
padding: 1px 4px; border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Effective access */
|
||||
.ea-empty, .ea-loading {
|
||||
|
|
@ -227,6 +238,7 @@
|
|||
</select>
|
||||
<button id="add-group-btn" disabled>Add to group</button>
|
||||
</div>
|
||||
<p id="add-group-hint" class="ud-hint" style="display:none;"></p>
|
||||
</section>
|
||||
|
||||
<!-- Effective access -->
|
||||
|
|
@ -340,6 +352,14 @@ async function loadMemberships() {
|
|||
}
|
||||
memberships = await r.json();
|
||||
renderMemberships();
|
||||
// loadAll() fires loadGroups() and loadMemberships() in parallel; if
|
||||
// groups resolved first, refreshGroupDropdown() saw the initial empty
|
||||
// memberships array and listed groups the user is already in. Re-run
|
||||
// here so the final dropdown reflects both data sets regardless of
|
||||
// which fetch completes first. Cheap (in-memory only) and idempotent.
|
||||
if (allGroups.length > 0) {
|
||||
refreshGroupDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMemberships() {
|
||||
|
|
@ -401,22 +421,54 @@ async function loadGroups() {
|
|||
|
||||
function refreshGroupDropdown() {
|
||||
const sel = document.getElementById("add-group-select");
|
||||
const hint = document.getElementById("add-group-hint");
|
||||
const memberOf = new Set(memberships.map(m => m.group_id));
|
||||
sel.innerHTML = '<option value="">— Pick a group —</option>';
|
||||
let googleManagedSkipped = 0;
|
||||
let assignableCount = 0;
|
||||
for (const g of allGroups) {
|
||||
if (memberOf.has(g.id)) continue; // already a member, hide
|
||||
if (g.is_google_managed) continue; // membership owned by Workspace —
|
||||
// includes mapped Admin / Everyone when
|
||||
// AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL is
|
||||
if (g.is_google_managed) { // membership owned by Workspace —
|
||||
googleManagedSkipped++; // includes mapped Admin / Everyone when
|
||||
continue; // AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL is
|
||||
// set. The API would 409 on POST
|
||||
// anyway; hiding the option keeps the
|
||||
// picker honest about what's grantable.
|
||||
}
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g.id;
|
||||
opt.textContent = g.name + (g.is_system ? " (system)" : "");
|
||||
sel.appendChild(opt);
|
||||
assignableCount++;
|
||||
}
|
||||
document.getElementById("add-group-btn").disabled = assignableCount === 0;
|
||||
|
||||
// When the dropdown ends up empty, explain why instead of leaving the
|
||||
// admin staring at a silent "— Pick a group —" placeholder. Three cases:
|
||||
// (a) user is already in every existing group;
|
||||
// (b) every remaining group is Google-Workspace-managed (POST would 409);
|
||||
// (c) no groups exist at all (fresh deploy with the system seeds masked
|
||||
// via AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL). Cases (b)/(c) point the
|
||||
// admin at /admin/groups so they can create a custom group whose
|
||||
// membership flows through Agnes itself.
|
||||
if (assignableCount === 0) {
|
||||
if (allGroups.length === 0) {
|
||||
hint.textContent = "No groups exist on this server.";
|
||||
hint.style.display = "block";
|
||||
} else if (googleManagedSkipped > 0) {
|
||||
hint.innerHTML = (
|
||||
"All assignable groups are managed by Google Workspace — membership flows from " +
|
||||
"<code>admin.google.com</code>. To grant access manually, create a custom Agnes group at " +
|
||||
"<a href=\"/admin/groups\">/admin/groups</a>."
|
||||
);
|
||||
hint.style.display = "block";
|
||||
} else {
|
||||
hint.textContent = "User is already a member of every group.";
|
||||
hint.style.display = "block";
|
||||
}
|
||||
} else {
|
||||
hint.style.display = "none";
|
||||
}
|
||||
document.getElementById("add-group-btn").disabled = sel.options.length <= 1;
|
||||
}
|
||||
|
||||
document.getElementById("add-group-select").addEventListener("change", e => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue