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.
649 lines
28 KiB
HTML
649 lines
28 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Users — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* Override base.html's 800px .container cap for this wide table. */
|
|
.container:has(.users-page) { max-width: none; padding: 24px 16px; }
|
|
.users-page { max-width: 1400px; margin: 0 auto; padding: 0; }
|
|
.users-toolbar {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
gap: 16px; margin-bottom: 20px; flex-wrap: wrap;
|
|
}
|
|
.users-title { margin: 0; font-size: 22px; font-weight: 600; }
|
|
.users-search {
|
|
flex: 1; max-width: 360px;
|
|
padding: 8px 12px 8px 36px;
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
background: var(--surface, #fff) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' 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 12px center;
|
|
}
|
|
.users-search:focus { outline: 2px solid var(--primary, #6366f1); outline-offset: -1px; }
|
|
|
|
.users-table-wrap {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
overflow-x: auto;
|
|
}
|
|
.users-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.users-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;
|
|
}
|
|
.users-table tbody td {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
vertical-align: middle;
|
|
}
|
|
.users-table tbody tr:last-child td { border-bottom: none; }
|
|
.users-table tbody tr.is-deactivated { opacity: 0.55; }
|
|
.users-table tbody tr:hover { background: var(--border-light, #fafafa); }
|
|
|
|
/* Whole user-info cell is the click target for the detail page —
|
|
anchor wraps avatar + name + email so the entire block lights up
|
|
on hover, not just one line. Defaults to inheriting text color so
|
|
the cell doesn't render in browser link blue; .name turns primary
|
|
blue on hover as the affordance cue. */
|
|
.user-cell {
|
|
display: flex; align-items: center; gap: 10px;
|
|
color: inherit; text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.user-cell:hover .user-meta .name,
|
|
.user-cell.no-name:hover .email { color: var(--primary, #4338ca); }
|
|
.user-avatar {
|
|
width: 32px; height: 32px; border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 12px; font-weight: 600; color: #fff;
|
|
flex-shrink: 0;
|
|
}
|
|
.user-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
.user-meta .name { font-weight: 500; color: var(--text-primary, #111827); }
|
|
.user-meta .email { font-size: 11px; color: var(--text-secondary, #6b7280); }
|
|
.user-cell.no-name .name { display: none; }
|
|
.user-cell.no-name .email { font-size: 13px; color: var(--text-primary, #111827); font-weight: 500; }
|
|
|
|
/* Membership chips — colors match the /admin/groups origin pills so a
|
|
user's group cell tells the same story at a glance:
|
|
Admin → yellow (seeded admin role)
|
|
Everyone → gray (seeded default group, low signal)
|
|
google_sync → green (synced from Workspace, not editable here)
|
|
custom (default) → purple (admin-created via UI/CLI)
|
|
The Admin/Everyone names take precedence over origin so a row mapped
|
|
via AGNES_GROUP_{ADMIN,EVERYONE}_EMAIL keeps its canonical color. */
|
|
.group-chips {
|
|
display: flex; flex-wrap: wrap; gap: 4px;
|
|
max-width: 320px;
|
|
}
|
|
.group-chip {
|
|
display: inline-block;
|
|
padding: 3px 8px; border-radius: 999px;
|
|
font-size: 11px; font-weight: 500;
|
|
white-space: nowrap;
|
|
background: #ede9fe; color: #6d28d9; /* default = custom (purple) */
|
|
}
|
|
.group-chip.is-admin { background: #fef3c7; color: #92400e; font-weight: 600; }
|
|
.group-chip.is-everyone { background: #f3f4f6; color: #4b5563; }
|
|
.group-chip.is-google_sync { background: #dcfce7; color: #166534; }
|
|
.group-chip.is-custom { background: #ede9fe; color: #6d28d9; }
|
|
.group-chips-empty {
|
|
color: var(--text-secondary, #9ca3af);
|
|
font-size: 11px; font-style: italic;
|
|
}
|
|
|
|
/* Toggle switch */
|
|
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
|
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
|
.toggle-slider {
|
|
position: absolute; cursor: pointer; inset: 0;
|
|
background: #cbd5e1; border-radius: 999px; transition: 0.2s;
|
|
}
|
|
.toggle-slider::before {
|
|
content: ""; position: absolute; left: 2px; top: 2px;
|
|
width: 16px; height: 16px; background: #fff; border-radius: 50%;
|
|
transition: 0.2s;
|
|
}
|
|
.toggle input:checked + .toggle-slider { background: #10b981; }
|
|
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
|
|
.toggle input:focus-visible + .toggle-slider { outline: 2px solid var(--primary, #6366f1); outline-offset: 2px; }
|
|
|
|
.date-cell { color: var(--text-secondary, #6b7280); font-size: 12px; white-space: nowrap; }
|
|
|
|
.row-actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: nowrap; white-space: nowrap; }
|
|
.icon-btn {
|
|
background: transparent; border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
|
|
padding: 5px 10px; font-size: 12px; cursor: pointer;
|
|
color: var(--text-secondary, #6b7280); transition: all 0.15s;
|
|
text-decoration: none; line-height: 1.4;
|
|
white-space: nowrap;
|
|
}
|
|
.icon-btn:hover { color: var(--text-primary, #111827); border-color: #cbd5e1; background: #f9fafb; }
|
|
.icon-btn.danger:hover { color: #b91c1c; border-color: #fecaca; background: #fef2f2; }
|
|
|
|
.users-empty, .users-loading {
|
|
text-align: center; padding: 48px 16px;
|
|
color: var(--text-secondary, #6b7280); font-size: 13px;
|
|
}
|
|
.users-empty .big { font-size: 15px; color: var(--text-primary, #111827); margin-bottom: 6px; font-weight: 500; }
|
|
|
|
.skeleton-row td { padding: 12px 16px; }
|
|
.skeleton-row .bar {
|
|
background: linear-gradient(90deg, #eef2f7 25%, #e2e8f0 37%, #eef2f7 63%);
|
|
background-size: 400% 100%; animation: skeleton 1.4s ease infinite;
|
|
height: 10px; border-radius: 4px;
|
|
}
|
|
@keyframes skeleton { 0% { background-position: 100% 50% } 100% { background-position: 0 50% } }
|
|
|
|
/* Modal */
|
|
.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: 440px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
|
}
|
|
.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 input[type="email"], .modal-card input[type="password"], .modal-card select {
|
|
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);
|
|
}
|
|
.modal-card input:focus, .modal-card select:focus { outline: 2px solid var(--primary, #6366f1); outline-offset: -1px; }
|
|
.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; transition: all 0.15s;
|
|
}
|
|
.modal-btn:hover { background: var(--border-light, #f9fafb); }
|
|
.modal-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
|
|
.modal-btn.primary:hover { filter: brightness(1.05); }
|
|
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
.modal-btn.danger:hover { filter: brightness(1.05); }
|
|
|
|
.token-reveal {
|
|
margin: 12px 0;
|
|
padding: 12px; border-radius: 8px;
|
|
background: #fffbeb; border: 1px solid #fcd34d;
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
font-size: 12px; word-break: break-all;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.token-reveal code { flex: 1; }
|
|
.copy-btn {
|
|
background: var(--primary, #6366f1); color: #fff; border: none;
|
|
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 500;
|
|
cursor: pointer; flex-shrink: 0;
|
|
}
|
|
.copy-btn.copied { background: #10b981; }
|
|
|
|
/* Toast */
|
|
.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="users-page">
|
|
<div class="users-toolbar">
|
|
<h2 class="users-title">Users</h2>
|
|
<input id="user-search" type="search" class="users-search" placeholder="Filter by email or name…" autocomplete="off">
|
|
<button class="modal-btn primary" id="open-create-btn">+ Add user</button>
|
|
</div>
|
|
|
|
<div class="users-table-wrap">
|
|
<table class="users-table" id="users-table">
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Groups</th>
|
|
<th>Active</th>
|
|
<th>Created</th>
|
|
<th>Deactivated</th>
|
|
<th style="text-align:right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="users-tbody"></tbody>
|
|
</table>
|
|
<div id="users-loading" class="users-loading">Loading users…</div>
|
|
<div id="users-empty" class="users-empty" style="display:none;">
|
|
<div class="big">No users yet</div>
|
|
<div>Click <strong>Add user</strong> to invite the first one.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create user modal -->
|
|
<div class="modal-backdrop" id="create-modal" role="dialog" aria-modal="true" aria-labelledby="create-modal-title">
|
|
<div class="modal-card">
|
|
<h3 id="create-modal-title">Add user</h3>
|
|
<p class="sub">Invites a new account. When "Send invitation link" is ticked we generate a setup link the user can follow to pick their own password.</p>
|
|
<label for="new-email">Email</label>
|
|
<input id="new-email" type="email" required autocomplete="off">
|
|
<label for="new-name">Name (optional)</label>
|
|
<input id="new-name" type="text" autocomplete="off">
|
|
<p class="sub" style="margin-top: 12px; font-size: 12px;">
|
|
New users start with no group memberships — assign them on the user
|
|
detail page after creating. They are auto-added to the <code>Everyone</code>
|
|
group at creation.
|
|
</p>
|
|
<label style="display:flex; align-items:center; gap:8px; margin-top:12px; font-size:13px; color: var(--text-primary, #111827); font-weight:500;">
|
|
<input id="new-send-invite" type="checkbox" checked>
|
|
Send invitation link (generates setup token, emails it if transport is configured)
|
|
</label>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="create-modal">Cancel</button>
|
|
<button class="modal-btn primary" id="confirm-create-btn">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Set password modal -->
|
|
<div class="modal-backdrop" id="setpwd-modal" role="dialog" aria-modal="true" aria-labelledby="setpwd-title">
|
|
<div class="modal-card">
|
|
<h3 id="setpwd-title">Set password</h3>
|
|
<p class="sub" id="setpwd-target"></p>
|
|
<label for="setpwd-input">New password (min 8 chars)</label>
|
|
<input id="setpwd-input" type="password" autocomplete="new-password">
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="setpwd-modal">Cancel</button>
|
|
<button class="modal-btn primary" id="confirm-setpwd-btn">Set password</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reset URL reveal modal -->
|
|
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title">
|
|
<div class="modal-card">
|
|
<h3 id="reset-title">Password reset link</h3>
|
|
<p class="sub" id="reset-target"></p>
|
|
<p class="sub" id="reset-transport-note"></p>
|
|
<div class="token-reveal">
|
|
<code id="reset-token-text"></code>
|
|
<button class="copy-btn" id="reset-copy-btn">Copy</button>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn primary" data-close-modal="reset-modal">Done</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Invitation link modal -->
|
|
<div class="modal-backdrop" id="invite-modal" role="dialog" aria-modal="true" aria-labelledby="invite-title">
|
|
<div class="modal-card">
|
|
<h3 id="invite-title">Invitation link</h3>
|
|
<p class="sub" id="invite-target"></p>
|
|
<p class="sub" id="invite-transport-note"></p>
|
|
<div class="token-reveal">
|
|
<code id="invite-url-text"></code>
|
|
<button class="copy-btn" id="invite-copy-btn">Copy</button>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn primary" data-close-modal="invite-modal">Done</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirm dialog -->
|
|
<div class="modal-backdrop" id="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
|
<div class="modal-card">
|
|
<h3 id="confirm-title">Are you sure?</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">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
|
|
|
<script>
|
|
const API = "/api/users";
|
|
// Server-injected env: empty string = no prefix configured. Used to
|
|
// shorten google-sync group chips (e.g. "grp_acme_legal@workspace.example.com"
|
|
// → "Legal") so the membership cell stays readable. Same shape used on
|
|
// /admin/groups; keeping the surface identical.
|
|
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;
|
|
}
|
|
|
|
// Strip @domain, then the configured prefix (case-insensitive), then any
|
|
// leading separators, then capitalize. Falls back to the raw local-part
|
|
// when the chain leaves nothing meaningful — better than rendering an
|
|
// empty chip. Only safe to apply to google_sync rows (whose `name` is
|
|
// the raw Workspace email); calling this on a custom group name like
|
|
// "data-team" would over-capitalize it.
|
|
function deriveDisplayName(fullEmail) {
|
|
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 fmtDate(s) { return s ? s.slice(0, 16).replace("T", " ") : "—"; }
|
|
function initials(u) {
|
|
const src = (u.name || u.email || "?").trim();
|
|
const parts = src.split(/[\s@.]+/).filter(Boolean);
|
|
return ((parts[0]?.[0] || "?") + (parts[1]?.[0] || "")).toUpperCase();
|
|
}
|
|
function avatarColor(s) {
|
|
// Stable hash → hue
|
|
let h = 0;
|
|
for (const c of s || "") h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
return `hsl(${h % 360}, 55%, 50%)`;
|
|
}
|
|
|
|
// ── Toast ──
|
|
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);
|
|
}
|
|
|
|
// ── Modal helpers ──
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add("is-open");
|
|
const focusable = document.querySelector(`#${id} input, #${id} select, #${id} button.primary`);
|
|
focusable && focusable.focus();
|
|
}
|
|
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"));
|
|
});
|
|
|
|
// Generic confirm using the modal — returns a Promise<boolean>
|
|
function confirmModal(text) {
|
|
const modal = document.getElementById("confirm-modal");
|
|
document.getElementById("confirm-text").textContent = text;
|
|
return new Promise(resolve => {
|
|
const okBtn = document.getElementById("confirm-ok-btn");
|
|
const cancel = () => { closeModal("confirm-modal"); cleanup(); resolve(false); };
|
|
const ok = () => { closeModal("confirm-modal"); cleanup(); resolve(true); };
|
|
function cleanup() {
|
|
okBtn.removeEventListener("click", ok);
|
|
modal.removeEventListener("click", backdropCancel);
|
|
}
|
|
function backdropCancel(e) { if (e.target === modal) cancel(); }
|
|
okBtn.addEventListener("click", ok, { once: true });
|
|
modal.addEventListener("click", backdropCancel);
|
|
openModal("confirm-modal");
|
|
});
|
|
}
|
|
|
|
// ── State ──
|
|
let allUsers = [];
|
|
let filterText = "";
|
|
|
|
function renderUsers() {
|
|
const tbody = document.getElementById("users-tbody");
|
|
const loading = document.getElementById("users-loading");
|
|
const empty = document.getElementById("users-empty");
|
|
loading.style.display = "none";
|
|
|
|
const ft = filterText.trim().toLowerCase();
|
|
const filtered = ft
|
|
? allUsers.filter(u => (u.email || "").toLowerCase().includes(ft) || (u.name || "").toLowerCase().includes(ft))
|
|
: allUsers;
|
|
|
|
if (allUsers.length === 0) {
|
|
empty.style.display = "block";
|
|
tbody.innerHTML = "";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
|
|
if (filtered.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="6" class="users-loading">No matches for "${esc(filterText)}"</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = "";
|
|
for (const u of filtered) {
|
|
const tr = document.createElement("tr");
|
|
if (!u.active) tr.classList.add("is-deactivated");
|
|
const hasName = !!(u.name && u.name !== u.email);
|
|
const groups = Array.isArray(u.groups) ? u.groups : [];
|
|
const chipsHtml = groups.length === 0
|
|
? `<span class="group-chips-empty">— no groups —</span>`
|
|
: `<div class="group-chips">${
|
|
groups.map(g => {
|
|
// Name match wins over origin so the env-mapped Admin/Everyone
|
|
// (whose origin is 'google_sync') keep their canonical color.
|
|
const cls = g.name === "Admin" ? "is-admin"
|
|
: g.name === "Everyone" ? "is-everyone"
|
|
: `is-${g.origin || "custom"}`;
|
|
// Shorten google-sync chip text: the API stores the full
|
|
// Workspace email as the group's `name`, but the chip cell
|
|
// needs to fit ~5 chips per row. Hover reveals the full
|
|
// email via `title`. Custom / Admin / Everyone keep the raw
|
|
// name (deriveDisplayName would over-capitalize "data-team").
|
|
const display = (g.origin === "google_sync" && g.name !== "Admin" && g.name !== "Everyone")
|
|
? deriveDisplayName(g.name) : g.name;
|
|
return `<span class="group-chip ${cls}" title="${esc(g.name)}">${esc(display)}</span>`;
|
|
}).join("")
|
|
}</div>`;
|
|
tr.innerHTML = `
|
|
<td>
|
|
<a class="user-cell ${hasName ? "" : "no-name"}" href="/admin/users/${encodeURIComponent(u.id)}">
|
|
<div class="user-avatar" style="background:${avatarColor(u.email || u.id)}">${esc(initials(u))}</div>
|
|
<div class="user-meta">
|
|
<span class="name">${esc(u.name || "")}</span>
|
|
<span class="email">${esc(u.email)}</span>
|
|
</div>
|
|
</a>
|
|
</td>
|
|
<td>${chipsHtml}</td>
|
|
<td>
|
|
<label class="toggle">
|
|
<input type="checkbox" ${u.active ? "checked" : ""} data-action="toggle-active" data-user-id="${esc(u.id)}">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td class="date-cell">${fmtDate(u.created_at)}</td>
|
|
<td class="date-cell">${u.deactivated_at ? fmtDate(u.deactivated_at) : "—"}</td>
|
|
<td>
|
|
<div class="row-actions">
|
|
<a class="icon-btn" href="/admin/users/${encodeURIComponent(u.id)}" title="Open detail view: memberships + effective access">Detail</a>
|
|
<a class="icon-btn" href="/admin/tokens?user=${encodeURIComponent(u.email || "")}" title="View this user's personal access tokens">Tokens</a>
|
|
${u.is_sso_user ? "" : `
|
|
<button class="icon-btn" data-action="reset-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Generate a reset link (user picks their own new password)">Reset</button>
|
|
<button class="icon-btn" data-action="set-password" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" title="Assign a password directly">Set pwd</button>
|
|
<button class="icon-btn danger" data-action="delete-user" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}">Delete</button>
|
|
`}
|
|
</div>
|
|
</td>`;
|
|
tbody.appendChild(tr);
|
|
}
|
|
|
|
// Wire up actions via delegation-like loop
|
|
tbody.querySelectorAll('[data-action="toggle-active"]').forEach(el =>
|
|
el.addEventListener("change", () => toggleActive(el.dataset.userId, el.checked)));
|
|
tbody.querySelectorAll('[data-action="reset-password"]').forEach(el =>
|
|
el.addEventListener("click", () => resetPassword(el.dataset.userId, el.dataset.userEmail)));
|
|
tbody.querySelectorAll('[data-action="set-password"]').forEach(el =>
|
|
el.addEventListener("click", () => openSetPassword(el.dataset.userId, el.dataset.userEmail)));
|
|
tbody.querySelectorAll('[data-action="delete-user"]').forEach(el =>
|
|
el.addEventListener("click", () => delUser(el.dataset.userId, el.dataset.userEmail)));
|
|
}
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const r = await fetch(API, { credentials: "include" });
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
allUsers = await r.json();
|
|
renderUsers();
|
|
} catch (e) {
|
|
document.getElementById("users-loading").textContent = "Failed to load users: " + e.message;
|
|
toast("Failed to load users", "error");
|
|
}
|
|
}
|
|
|
|
document.getElementById("user-search").addEventListener("input", e => {
|
|
filterText = e.target.value;
|
|
renderUsers();
|
|
});
|
|
|
|
async function toggleActive(id, active) {
|
|
const path = active ? "activate" : "deactivate";
|
|
const r = await fetch(`${API}/${id}/${path}`, { method: "POST", credentials: "include" });
|
|
if (!r.ok) {
|
|
toast("Failed: " + (await r.text()), "error");
|
|
loadUsers();
|
|
return;
|
|
}
|
|
toast(active ? "User activated" : "User deactivated", "success");
|
|
loadUsers();
|
|
}
|
|
|
|
async function patch(id, body, successMsg) {
|
|
const r = await fetch(`${API}/${id}`, {
|
|
method: "PATCH", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!r.ok) { toast("Failed: " + (await r.text()), "error"); return; }
|
|
toast(successMsg, "success");
|
|
loadUsers();
|
|
}
|
|
|
|
// ── Reset password ──
|
|
async function resetPassword(id, email) {
|
|
if (!await confirmModal(`Generate a password-reset link for ${email}?`)) return;
|
|
const r = await fetch(`${API}/${id}/reset-password`, { method: "POST", credentials: "include" });
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok) { toast("Failed: " + (data.detail || r.status), "error"); return; }
|
|
document.getElementById("reset-target").textContent = `For ${email}`;
|
|
document.getElementById("reset-transport-note").textContent = data.email_sent
|
|
? "Email sent. Copy the link below only if you need to deliver it manually."
|
|
: "Email transport unavailable — send this link to the user directly.";
|
|
document.getElementById("reset-token-text").textContent = data.reset_url;
|
|
const copyBtn = document.getElementById("reset-copy-btn");
|
|
copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied");
|
|
copyBtn.onclick = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(data.reset_url);
|
|
copyBtn.textContent = "Copied!"; copyBtn.classList.add("copied");
|
|
setTimeout(() => { copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied"); }, 1500);
|
|
} catch { toast("Copy failed — select the text manually", "error"); }
|
|
};
|
|
openModal("reset-modal");
|
|
}
|
|
|
|
// ── Set password ──
|
|
function openSetPassword(id, email) {
|
|
document.getElementById("setpwd-target").textContent = `For ${email}`;
|
|
const input = document.getElementById("setpwd-input");
|
|
input.value = "";
|
|
openModal("setpwd-modal");
|
|
document.getElementById("confirm-setpwd-btn").onclick = async () => {
|
|
const pwd = input.value;
|
|
if (!pwd || pwd.length < 8) { toast("Password must be at least 8 characters", "error"); return; }
|
|
const r = await fetch(`${API}/${id}/set-password`, {
|
|
method: "POST", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ password: pwd }),
|
|
});
|
|
if (!r.ok) { toast("Failed: " + (await r.text()), "error"); return; }
|
|
closeModal("setpwd-modal");
|
|
toast("Password updated", "success");
|
|
};
|
|
}
|
|
|
|
// ── Delete ──
|
|
async function delUser(id, email) {
|
|
if (!await confirmModal(`Delete ${email}? This cannot be undone.`)) return;
|
|
const r = await fetch(`${API}/${id}`, { method: "DELETE", credentials: "include" });
|
|
if (!r.ok) { toast("Failed: " + (await r.text()), "error"); return; }
|
|
toast("User deleted", "success");
|
|
loadUsers();
|
|
}
|
|
|
|
// ── Create ──
|
|
document.getElementById("open-create-btn").addEventListener("click", () => {
|
|
document.getElementById("new-email").value = "";
|
|
document.getElementById("new-name").value = "";
|
|
document.getElementById("new-send-invite").checked = true;
|
|
openModal("create-modal");
|
|
});
|
|
document.getElementById("confirm-create-btn").addEventListener("click", async () => {
|
|
const email = document.getElementById("new-email").value.trim();
|
|
const name = document.getElementById("new-name").value.trim();
|
|
const sendInvite = document.getElementById("new-send-invite").checked;
|
|
if (!email) { toast("Email is required", "error"); return; }
|
|
const r = await fetch(API, {
|
|
method: "POST", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, name: name || email.split("@")[0], send_invite: sendInvite }),
|
|
});
|
|
if (!r.ok) { toast("Failed: " + (await r.text()), "error"); return; }
|
|
const data = await r.json().catch(() => ({}));
|
|
closeModal("create-modal");
|
|
toast("User created", "success");
|
|
loadUsers();
|
|
if (data.invite_url) showInviteLink(email, data.invite_url, data.invite_email_sent);
|
|
});
|
|
|
|
function showInviteLink(email, url, emailSent) {
|
|
document.getElementById("invite-target").textContent = `For ${email}`;
|
|
document.getElementById("invite-transport-note").textContent = emailSent
|
|
? "Invitation email sent. Copy the link below only if you need to deliver it manually."
|
|
: "Email transport unavailable — send this link to the user directly.";
|
|
document.getElementById("invite-url-text").textContent = url;
|
|
const copyBtn = document.getElementById("invite-copy-btn");
|
|
copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied");
|
|
copyBtn.onclick = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
copyBtn.textContent = "Copied!"; copyBtn.classList.add("copied");
|
|
setTimeout(() => { copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied"); }, 1500);
|
|
} catch { toast("Copy failed — select the text manually", "error"); }
|
|
};
|
|
openModal("invite-modal");
|
|
}
|
|
|
|
loadUsers();
|
|
</script>
|
|
{% endblock %}
|