From 438ac78905a7fb55f8aee3dac95dc5471fcce3cd Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Thu, 7 May 2026 07:07:44 +0200 Subject: [PATCH] fix(admin/users): explain empty group dropdown instead of silent placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 5 ++ app/web/templates/admin_user_detail.html | 60 ++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7f01e..867a875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/web/templates/admin_user_detail.html b/app/web/templates/admin_user_detail.html index bf0d865..d83d4dd 100644 --- a/app/web/templates/admin_user_detail.html +++ b/app/web/templates/admin_user_detail.html @@ -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 @@ + @@ -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 = ''; + 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 " + + "admin.google.com. To grant access manually, create a custom Agnes group at " + + "/admin/groups." + ); + 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 => {