Cuts release 0.24.0.
## Highlights
- SSO-managed accounts read-only for password / delete operations (UI + API). New `is_sso_user` flag derived from group memberships.
- Admin/Everyone system rows show `google_sync` chip + Workspace email subtitle when env-mapped.
- Origin pill vocabulary unified across `/admin/groups`, `/admin/access`, `/admin/users`, `/admin/users/{id}`, `/profile` (Admin yellow, Everyone gray, google_sync green, custom purple).
- Effective-access readout no longer short-circuits for admin users — always renders per-resource breakdown.
- Schema migration v18 drops stranded non-google memberships in env-mapped Admin/Everyone groups (cleans up v13's blanket Everyone backfill).
## Devin findings addressed
- _is_sso_user requires source='google_sync' on system-group branches (so v13 system_seed memberships in env-mapped Everyone don't lock out the admin).
- POST add-to-group returns correct origin via _derive_origin (matching GET).
- 8 customer-specific token instances (groupon.com / foundryai) replaced with vendor-neutral placeholders across templates, tests, and CHANGELOG.
- deriveDisplayName name-skip for canonical "Admin"/"Everyone" so an overlapping AGNES_GOOGLE_GROUP_PREFIX doesn't mangle the chip text.
See CHANGELOG [0.24.0] for full notes.
426 lines
18 KiB
HTML
426 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Groups — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.container:has(.gp-page) { max-width: none; padding: 24px 16px; }
|
|
.gp-page { max-width: 1400px; margin: 0 auto; padding: 0; }
|
|
|
|
.gp-toolbar {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
gap: 12px; margin-bottom: 18px; flex-wrap: wrap;
|
|
}
|
|
.gp-title { margin: 0; font-size: 22px; font-weight: 600; }
|
|
.gp-sub { margin: 0; font-size: 13px; color: var(--text-secondary, #6b7280); }
|
|
.gp-search {
|
|
padding: 8px 12px 8px 32px; min-width: 280px;
|
|
border: 1px solid var(--border, #e5e7eb); border-radius: 8px;
|
|
font-size: 13px;
|
|
background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>") no-repeat 10px center;
|
|
}
|
|
|
|
.gp-table-wrap {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
.gp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.gp-table thead th {
|
|
text-align: left; padding: 12px 16px;
|
|
background: var(--border-light, #f9fafb);
|
|
border-bottom: 1px solid var(--border, #e5e7eb);
|
|
font-weight: 600; color: var(--text-secondary, #6b7280);
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
|
|
}
|
|
.gp-table tbody td {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
vertical-align: middle;
|
|
}
|
|
.gp-table tbody tr:last-child td { border-bottom: none; }
|
|
.gp-table tbody tr { cursor: pointer; }
|
|
.gp-table tbody tr:hover { background: var(--border-light, #fafafa); }
|
|
|
|
.gp-name {
|
|
font-weight: 500; color: var(--text-primary, #111827);
|
|
text-decoration: none;
|
|
}
|
|
.gp-name:hover { color: var(--primary, #4338ca); text-decoration: underline; }
|
|
.gp-name-sub {
|
|
display: block;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
|
font-size: 11px; font-weight: 400; color: var(--text-secondary, #6b7280);
|
|
margin-top: 2px;
|
|
}
|
|
.gp-desc {
|
|
color: var(--text-secondary, #6b7280); font-size: 12px;
|
|
max-width: 380px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.count-cell { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary, #4b5563); font-weight: 500; }
|
|
.date-cell { color: var(--text-secondary, #6b7280); font-size: 12px; white-space: nowrap; }
|
|
|
|
.origin-chip {
|
|
display: inline-block;
|
|
padding: 3px 10px; border-radius: 999px;
|
|
font-size: 10px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.4px;
|
|
}
|
|
.origin-system { background: #fef3c7; color: #92400e; }
|
|
.origin-custom { background: #ede9fe; color: #6d28d9; }
|
|
.origin-google_sync { background: #dcfce7; color: #166534; }
|
|
|
|
.gp-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
.icon-btn {
|
|
padding: 5px 10px; border-radius: 6px; font-size: 12px;
|
|
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
cursor: pointer; color: var(--text-primary, #111827);
|
|
text-decoration: none;
|
|
}
|
|
.icon-btn:hover { background: var(--border-light, #f9fafb); }
|
|
.icon-btn.danger { color: #b91c1c; border-color: #fecaca; }
|
|
.icon-btn.danger:hover { background: #fef2f2; }
|
|
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
.gp-loading, .gp-empty {
|
|
padding: 40px 16px; text-align: center;
|
|
color: var(--text-secondary, #6b7280); font-size: 13px;
|
|
}
|
|
.gp-empty .big { font-size: 15px; font-weight: 600; color: var(--text-primary, #111827); margin-bottom: 4px; }
|
|
|
|
.gp-btn {
|
|
padding: 8px 14px; border-radius: 8px;
|
|
font-size: 13px; font-weight: 500;
|
|
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
cursor: pointer;
|
|
}
|
|
.gp-btn:hover { background: var(--border-light, #f9fafb); }
|
|
.gp-btn.primary {
|
|
background: var(--primary, #6366f1); color: #fff;
|
|
border-color: var(--primary, #6366f1);
|
|
}
|
|
.gp-btn.primary:hover { filter: brightness(1.05); }
|
|
|
|
/* Modal — same vocabulary as the rest of the admin pages */
|
|
.modal-backdrop {
|
|
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
|
|
display: none; align-items: center; justify-content: center; z-index: 1000;
|
|
padding: 16px;
|
|
}
|
|
.modal-backdrop.is-open { display: flex; }
|
|
.modal-card {
|
|
background: var(--surface, #fff); border-radius: 12px;
|
|
padding: 24px; width: 100%; max-width: 480px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
|
max-height: 90vh; overflow-y: auto;
|
|
}
|
|
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
|
|
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
|
|
.modal-card label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary, #6b7280); margin: 12px 0 6px; }
|
|
.modal-card input[type="text"], .modal-card textarea {
|
|
width: 100%; padding: 9px 12px; border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 8px; font-size: 13px; box-sizing: border-box;
|
|
background: var(--surface, #fff); color: var(--text-primary, #111827);
|
|
font-family: inherit;
|
|
}
|
|
.modal-card textarea { min-height: 60px; resize: vertical; }
|
|
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
.modal-btn {
|
|
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
|
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
cursor: pointer;
|
|
}
|
|
.modal-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
|
|
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
|
|
.toast-stack {
|
|
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
|
|
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
|
|
}
|
|
.toast {
|
|
background: #111827; color: #fff; padding: 10px 16px;
|
|
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
|
opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s;
|
|
pointer-events: auto; max-width: 380px;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.success { background: #047857; }
|
|
.toast.error { background: #b91c1c; }
|
|
</style>
|
|
|
|
<div class="gp-page">
|
|
<div class="gp-toolbar">
|
|
<div>
|
|
<h2 class="gp-title">Groups</h2>
|
|
<p class="gp-sub">Named buckets that hold members and resource grants.
|
|
Manage who has access to what on <a href="/admin/access">Resource access</a>.</p>
|
|
</div>
|
|
<div style="display:flex; gap:10px; align-items:center;">
|
|
<input id="search" type="search" class="gp-search" placeholder="Filter by name or description…" autocomplete="off">
|
|
<button class="gp-btn primary" id="open-create-btn">+ New group</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="gp-table-wrap">
|
|
<table class="gp-table" id="groups-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Description</th>
|
|
<th>Origin</th>
|
|
<th class="count-cell">Members</th>
|
|
<th class="count-cell">Resources</th>
|
|
<th>Created</th>
|
|
<th style="text-align:right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="groups-tbody"></tbody>
|
|
</table>
|
|
<div id="groups-loading" class="gp-loading">Loading groups…</div>
|
|
<div id="groups-empty" class="gp-empty" style="display:none;">
|
|
<div class="big">No groups yet</div>
|
|
<div>Click <strong>+ New group</strong> to create one. <code>Admin</code> and <code>Everyone</code> are seeded automatically.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create / edit modal -->
|
|
<div class="modal-backdrop" id="group-modal" role="dialog" aria-modal="true">
|
|
<div class="modal-card">
|
|
<h3 id="group-modal-title">New group</h3>
|
|
<p class="sub">Pick a name that identifies a logical audience (e.g. <code>data-team</code>, <code>engineers</code>).</p>
|
|
|
|
<label for="group-name">Name</label>
|
|
<input id="group-name" type="text" autocomplete="off" placeholder="data-team">
|
|
<label for="group-desc">Description (optional)</label>
|
|
<textarea id="group-desc" autocomplete="off"></textarea>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="group-modal">Cancel</button>
|
|
<button class="modal-btn primary" id="group-save-btn">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirm delete modal -->
|
|
<div class="modal-backdrop" id="confirm-modal" role="dialog" aria-modal="true">
|
|
<div class="modal-card">
|
|
<h3 id="confirm-title">Delete group?</h3>
|
|
<p class="sub" id="confirm-text"></p>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="confirm-modal">Cancel</button>
|
|
<button class="modal-btn danger" id="confirm-ok-btn">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
|
|
|
<script>
|
|
const API = "/api/admin/groups";
|
|
// Server-injected env: empty string = no prefix configured. Used to derive a
|
|
// friendly display name from the full Workspace email stored as the group's
|
|
// `name` (e.g. "grp_acme_finance@example.com" → "Finance").
|
|
const GOOGLE_GROUP_PREFIX = {{ config.AGNES_GOOGLE_GROUP_PREFIX | tojson }};
|
|
|
|
function esc(s) { const d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }
|
|
function fmtDate(s) { return s ? String(s).slice(0, 16).replace("T", " ") : "-"; }
|
|
|
|
function deriveDisplayName(fullEmail) {
|
|
// Strip @domain, then strip the configured prefix (case-insensitive),
|
|
// then capitalize the first letter. Fallback to the raw local-part if
|
|
// anything looks off — better to show the email than render an empty cell.
|
|
if (!fullEmail) return "";
|
|
const local = String(fullEmail).split("@")[0] || String(fullEmail);
|
|
const px = (GOOGLE_GROUP_PREFIX || "").toLowerCase();
|
|
let s = local;
|
|
if (px && s.toLowerCase().startsWith(px)) s = s.slice(px.length);
|
|
s = s.replace(/^[_\-\s]+/, "");
|
|
if (!s) return local;
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
function toast(msg, kind = "") {
|
|
const el = document.createElement("div");
|
|
el.className = "toast " + kind;
|
|
el.textContent = msg;
|
|
document.getElementById("toast-stack").appendChild(el);
|
|
requestAnimationFrame(() => el.classList.add("show"));
|
|
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3500);
|
|
}
|
|
|
|
function openModal(id) { document.getElementById(id).classList.add("is-open"); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove("is-open"); }
|
|
document.querySelectorAll("[data-close-modal]").forEach(el =>
|
|
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
|
|
document.querySelectorAll(".modal-backdrop").forEach(el => {
|
|
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
|
|
});
|
|
document.addEventListener("keydown", e => {
|
|
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
|
|
});
|
|
|
|
let allGroups = [];
|
|
let editingId = null;
|
|
|
|
async function loadGroups() {
|
|
const r = await fetch(API, { credentials: "include" });
|
|
document.getElementById("groups-loading").style.display = "none";
|
|
if (!r.ok) {
|
|
toast("Failed to load: HTTP " + r.status, "error");
|
|
return;
|
|
}
|
|
allGroups = await r.json();
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
const tbody = document.getElementById("groups-tbody");
|
|
const empty = document.getElementById("groups-empty");
|
|
const filter = document.getElementById("search").value.trim().toLowerCase();
|
|
const filtered = !filter ? allGroups : allGroups.filter(g =>
|
|
(g.name || "").toLowerCase().includes(filter) ||
|
|
(g.description || "").toLowerCase().includes(filter)
|
|
);
|
|
if (filtered.length === 0) {
|
|
tbody.innerHTML = "";
|
|
empty.style.display = "block";
|
|
if (allGroups.length > 0) {
|
|
empty.querySelector(".big").textContent = "No matching groups";
|
|
empty.querySelector("div:last-child").textContent = "Try a different search.";
|
|
}
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
|
|
tbody.innerHTML = "";
|
|
for (const g of filtered) {
|
|
const tr = document.createElement("tr");
|
|
tr.dataset.id = g.id;
|
|
tr.style.cursor = "pointer";
|
|
const origin = g.origin || "custom";
|
|
// Read-only when the row is owned by Google sync OR a non-mapped system
|
|
// group (Admin/Everyone canonical name without the env mapping — those
|
|
// still cannot be renamed/deleted, but they accept admin-managed members).
|
|
// The `is_google_managed` flag from the API is the union we care about for
|
|
// hiding Edit/Delete in the list.
|
|
const isGoogleManaged = !!g.is_google_managed;
|
|
const isReadOnly = g.is_system || isGoogleManaged;
|
|
const actions = isReadOnly
|
|
? `<span style="color:#9ca3af;font-size:11px">read-only</span>`
|
|
: `<button class="icon-btn" data-action="edit">Edit</button>
|
|
<button class="icon-btn danger" data-action="delete">Delete</button>`;
|
|
// Subtitle rules for the name cell:
|
|
// - mapped_email present (Admin/Everyone wired to a Workspace group)
|
|
// → big = canonical name ("Admin"), subtitle = the Workspace email
|
|
// - google-managed user-created group → big = derived friendly name
|
|
// ("Finance"), subtitle = full Workspace email stored as `name`
|
|
// - everything else → single-line name, no subtitle
|
|
let nameCell;
|
|
if (g.mapped_email) {
|
|
nameCell = `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(g.name)}</a>
|
|
<span class="gp-name-sub">${esc(g.mapped_email)}</span>`;
|
|
} else if (isGoogleManaged) {
|
|
nameCell = `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(deriveDisplayName(g.name))}</a>
|
|
<span class="gp-name-sub">${esc(g.name)}</span>`;
|
|
} else {
|
|
nameCell = `<a class="gp-name" href="/admin/groups/${encodeURIComponent(g.id)}">${esc(g.name)}</a>`;
|
|
}
|
|
tr.innerHTML = `
|
|
<td>${nameCell}</td>
|
|
<td><span class="gp-desc">${esc(g.description || "")}</span></td>
|
|
<td><span class="origin-chip origin-${esc(origin)}">${esc(origin.replace("_"," "))}</span></td>
|
|
<td class="count-cell">${g.member_count || 0}</td>
|
|
<td class="count-cell">
|
|
${g.grant_count || 0}
|
|
${g.grant_count > 0 ? `<a class="icon-btn" style="margin-left:6px" href="/admin/access?group=${encodeURIComponent(g.id)}" data-action="grants">→</a>` : ""}
|
|
</td>
|
|
<td class="date-cell">${fmtDate(g.created_at)}</td>
|
|
<td><div class="gp-actions">${actions}</div></td>
|
|
`;
|
|
// Row click → detail (unless action button)
|
|
tr.addEventListener("click", e => {
|
|
if (e.target.closest("[data-action]")) return;
|
|
window.location.href = `/admin/groups/${encodeURIComponent(g.id)}`;
|
|
});
|
|
const editBtn = tr.querySelector('[data-action="edit"]');
|
|
if (editBtn) editBtn.addEventListener("click", e => { e.stopPropagation(); openEdit(g); });
|
|
const delBtn = tr.querySelector('[data-action="delete"]');
|
|
if (delBtn) delBtn.addEventListener("click", e => { e.stopPropagation(); openDelete(g); });
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
|
|
document.getElementById("search").addEventListener("input", render);
|
|
|
|
document.getElementById("open-create-btn").addEventListener("click", () => {
|
|
editingId = null;
|
|
document.getElementById("group-modal-title").textContent = "New group";
|
|
document.getElementById("group-name").value = "";
|
|
document.getElementById("group-desc").value = "";
|
|
openModal("group-modal");
|
|
setTimeout(() => document.getElementById("group-name").focus(), 50);
|
|
});
|
|
|
|
function openEdit(g) {
|
|
editingId = g.id;
|
|
document.getElementById("group-modal-title").textContent = "Edit group";
|
|
document.getElementById("group-name").value = g.name;
|
|
document.getElementById("group-desc").value = g.description || "";
|
|
openModal("group-modal");
|
|
}
|
|
|
|
document.getElementById("group-save-btn").addEventListener("click", async () => {
|
|
const name = document.getElementById("group-name").value.trim();
|
|
const description = document.getElementById("group-desc").value.trim();
|
|
if (!name) { toast("Name is required", "error"); return; }
|
|
try {
|
|
if (editingId) {
|
|
const r = await fetch(`${API}/${encodeURIComponent(editingId)}`, {
|
|
method: "PATCH", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, description: description || null }),
|
|
});
|
|
if (!r.ok) throw new Error((await r.json()).detail || r.status);
|
|
toast("Group updated", "success");
|
|
} else {
|
|
const r = await fetch(API, {
|
|
method: "POST", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, description: description || null }),
|
|
});
|
|
if (!r.ok) throw new Error((await r.json()).detail || r.status);
|
|
toast("Group created", "success");
|
|
}
|
|
closeModal("group-modal");
|
|
loadGroups();
|
|
} catch (e) {
|
|
toast("Save failed: " + e.message, "error");
|
|
}
|
|
});
|
|
|
|
function openDelete(g) {
|
|
document.getElementById("confirm-title").textContent = `Delete group "${g.name}"?`;
|
|
document.getElementById("confirm-text").textContent =
|
|
`Removes the group, its ${g.member_count || 0} member${g.member_count === 1 ? "" : "s"} and ${g.grant_count || 0} grant${g.grant_count === 1 ? "" : "s"}. Cannot be undone.`;
|
|
const ok = document.getElementById("confirm-ok-btn");
|
|
ok.onclick = async () => {
|
|
try {
|
|
const r = await fetch(`${API}/${encodeURIComponent(g.id)}`, {
|
|
method: "DELETE", credentials: "include",
|
|
});
|
|
if (!r.ok) throw new Error(r.status);
|
|
toast("Group deleted", "success");
|
|
closeModal("confirm-modal");
|
|
loadGroups();
|
|
} catch (e) {
|
|
toast("Delete failed: " + e.message, "error");
|
|
}
|
|
};
|
|
openModal("confirm-modal");
|
|
}
|
|
|
|
loadGroups();
|
|
</script>
|
|
{% endblock %}
|