This squashes 13 commits from ma/staging plus a small docstring translation
into a single coherent unit. Three workstreams.
== RBAC v13 redesign ==
- Drops core.viewer/analyst/km_admin/admin hierarchy and the
internal_roles / group_mappings / user_role_grants / plugin_access tables.
- Replaced by user_group_members + resource_grants. Atomic v12→v13 backfill
wrapped in BEGIN/COMMIT; ROLLBACK leaves schema_version at 12 for retry.
- Two authorization primitives in app.auth.access:
require_admin — Admin-group god-mode
require_resource_access(rt, "{path}") — entity-scoped grants
Single DB lookup per request; no session cache; no implies BFS.
- /admin/access UI (single page) replaces /admin/role-mapping +
/admin/plugin-access. CLI `da admin group/grant *` replaces
`da admin role/mapping/grant-role/revoke-role/effective-roles`.
- ResourceType.TABLE listing-only — admins can record table grants,
runtime enforcement still flows through legacy dataset_permissions
(migration plan in docs/TODO-rbac-data-enforcement.md).
== Claude Code marketplace ==
- Aggregated /marketplace.zip + /marketplace.git/* (PAT-gated,
RBAC-filtered, content-addressed cache via dulwich).
- Admin god-mode dropped on the marketplace surface — admins curate
their own view via grants like everyone else.
- Bare-repo cache materializes per RBAC-filtered ETag; stale entries
not pruned in this iteration (disclaimed in git_backend.py docstring).
== #81 #83 #44 security/ops hardening ==
- #81 Group A — orchestrator ATTACH allow-listing (extension/url/alias).
- #81 Group B — Keboola extractor 3-state exit codes:
0 success / 1 total fail / 2 PARTIAL fail
Sync API logs PARTIAL FAILURE alert on exit 2. Operators with binary
alerting must teach it the new partial signal.
- #81 Group C — schema v10 view_ownership; rejects silent overwrite
of a prior connector's view name on collision.
- #81 Group D — extractor-side identifier validation.
- #83 — Jira webhook fail-closed when JIRA_WEBHOOK_SECRET unset
+ path-traversal fix.
- #44 — entire /api/scripts/* surface is admin-only (planted-script +
sandbox-bypass risk closed).
== Web UI polish + deploy fix ==
- /admin/access: live grant-count badges (no stale snapshot revert),
shared-header CSS link added to /catalog and /admin/{tables,permissions},
per-resource-type colored stripes.
- docker-compose.host-mount.yml: bind,rbind so dual-disk hosts don't
silently shadow sub-mounts and write state to the wrong disk.
== OSS vendor-neutralization (waves 1+2) ==
- scripts/grpn/ → scripts/ops/. Customer-specific identifiers
(project IDs, internal hostnames, dev/prod VM IPs, brand names)
replaced with placeholders across code, docs, Terraform, Caddyfile,
OAuth probe, and planning docs. Downstream infra repos that copied
scripts/grpn/agnes-tls-rotate.sh or agnes-auto-upgrade.sh must
update the path.
== Translation ==
- src/repositories/user_groups.py::ensure_system docstring translated
from Czech to English for codebase consistency.
Co-authored-by: Mina Rustamyan <mina@keboola.com>
509 lines
18 KiB
HTML
509 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ target_user.email }} — User detail — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.container:has(.ud-page) { max-width: none; padding: 24px 16px; }
|
|
.ud-page { max-width: 1100px; margin: 0 auto; padding: 0; }
|
|
|
|
.ud-header {
|
|
display: flex; align-items: center; gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.ud-back {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
text-decoration: none; color: var(--text-secondary, #6b7280);
|
|
font-size: 13px;
|
|
}
|
|
.ud-back:hover { color: var(--text-primary, #111827); }
|
|
.ud-title-block { flex: 1; }
|
|
.ud-title { font-size: 22px; font-weight: 600; margin: 0; }
|
|
.ud-subtitle {
|
|
font-size: 13px; color: var(--text-secondary, #6b7280);
|
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
margin-top: 2px;
|
|
}
|
|
.ud-status-pill {
|
|
display: inline-block; padding: 3px 10px; border-radius: 999px;
|
|
font-size: 11px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
.ud-status-pill.active { background: #dcfce7; color: #166534; }
|
|
.ud-status-pill.inactive { background: #fee2e2; color: #991b1b; }
|
|
.ud-status-pill.admin { background: #fef3c7; color: #92400e; margin-left: 6px; }
|
|
|
|
.ud-section {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
margin-bottom: 16px;
|
|
overflow: hidden;
|
|
}
|
|
.ud-section-head {
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid var(--border, #e5e7eb);
|
|
background: var(--border-light, #f9fafb);
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
.ud-section-head h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
.ud-section-head .sub { font-size: 11px; color: var(--text-secondary, #6b7280); }
|
|
|
|
/* Account actions */
|
|
.account-grid {
|
|
padding: 16px 18px;
|
|
display: grid; grid-template-columns: 1fr auto; gap: 12px;
|
|
align-items: center;
|
|
}
|
|
.account-action-btn {
|
|
padding: 7px 14px; border-radius: 6px; font-size: 12px;
|
|
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
cursor: pointer;
|
|
}
|
|
.account-action-btn:hover { background: var(--border-light, #f9fafb); }
|
|
.account-action-btn.danger { color: #b91c1c; border-color: #fecaca; }
|
|
.account-action-btn.danger:hover { background: #fef2f2; }
|
|
|
|
/* Memberships table */
|
|
.members-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.members-table thead th {
|
|
text-align: left; padding: 10px 18px;
|
|
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;
|
|
}
|
|
.members-table tbody td {
|
|
padding: 10px 18px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
}
|
|
.members-table tbody tr:last-child td { border-bottom: none; }
|
|
.members-table tbody tr:hover { background: var(--border-light, #fafafa); }
|
|
/* Source as quiet metadata, not a status pill — group name dominates. */
|
|
.source-meta {
|
|
font-size: 12px; color: var(--text-secondary, #9ca3af);
|
|
font-weight: 400;
|
|
}
|
|
.source-meta .label { font-style: italic; }
|
|
.source-meta .added {
|
|
color: var(--text-secondary, #9ca3af);
|
|
font-size: 11px; margin-left: 6px;
|
|
}
|
|
.group-link {
|
|
color: var(--text-primary, #111827); font-weight: 500;
|
|
text-decoration: none;
|
|
}
|
|
.group-link:hover { color: var(--primary, #4338ca); text-decoration: underline; }
|
|
|
|
.add-member-row {
|
|
padding: 12px 18px;
|
|
background: var(--border-light, #f9fafb);
|
|
border-top: 1px solid var(--border-light, #f3f4f6);
|
|
display: flex; gap: 8px; align-items: center;
|
|
}
|
|
.add-member-row select {
|
|
flex: 1; padding: 7px 10px;
|
|
border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
|
|
font-size: 13px; background: var(--surface, #fff);
|
|
}
|
|
.add-member-row button {
|
|
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
|
background: var(--primary, #6366f1); color: #fff;
|
|
border: 1px solid var(--primary, #6366f1); cursor: pointer;
|
|
}
|
|
.add-member-row button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
/* Effective access */
|
|
.ea-empty, .ea-loading {
|
|
padding: 24px 18px; text-align: center;
|
|
color: var(--text-secondary, #6b7280); font-size: 13px;
|
|
}
|
|
.ea-admin-pill {
|
|
margin: 18px;
|
|
padding: 16px;
|
|
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
|
border: 1px solid #f59e0b;
|
|
border-radius: 8px;
|
|
display: flex; align-items: center; gap: 12px;
|
|
}
|
|
.ea-admin-pill .icon { font-size: 22px; }
|
|
.ea-admin-pill .text { font-size: 13px; color: #78350f; line-height: 1.4; }
|
|
.ea-admin-pill strong { color: #422006; }
|
|
.ea-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.ea-table thead th {
|
|
text-align: left; padding: 10px 18px;
|
|
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;
|
|
}
|
|
.ea-table tbody td {
|
|
padding: 10px 18px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
vertical-align: top;
|
|
}
|
|
.ea-table tbody tr:last-child td { border-bottom: none; }
|
|
.ea-rid {
|
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
font-size: 12px;
|
|
}
|
|
.ea-via {
|
|
font-size: 11px; color: var(--text-secondary, #6b7280);
|
|
}
|
|
.ea-via .via-group {
|
|
display: inline-block; margin-right: 6px;
|
|
padding: 1px 6px; border-radius: 4px;
|
|
background: #e0e7ff; color: #3730a3; font-weight: 500;
|
|
}
|
|
|
|
.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="ud-page" data-user-id="{{ target_user.id }}">
|
|
<div class="ud-header">
|
|
<a href="/admin/users" class="ud-back">← Back to users</a>
|
|
<div class="ud-title-block">
|
|
<h1 class="ud-title">
|
|
{{ target_user.name or target_user.email }}
|
|
<span id="status-pill" class="ud-status-pill"
|
|
style="display:none; vertical-align: middle; margin-left: 8px;"></span>
|
|
<span id="admin-pill" class="ud-status-pill admin" style="display:none; vertical-align:middle;">Admin</span>
|
|
</h1>
|
|
<div class="ud-subtitle">{{ target_user.email }} · id {{ target_user.id[:8] }}…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Account actions -->
|
|
<section class="ud-section">
|
|
<div class="ud-section-head">
|
|
<h2>Account</h2>
|
|
</div>
|
|
<div class="account-grid">
|
|
<div id="account-status-text" style="font-size:13px;color:var(--text-secondary,#6b7280);">
|
|
Loading…
|
|
</div>
|
|
<div style="display:flex; gap:8px;">
|
|
<button class="account-action-btn" id="reset-pw-btn">Reset password</button>
|
|
<button class="account-action-btn" id="toggle-active-btn">Deactivate</button>
|
|
<button class="account-action-btn danger" id="delete-user-btn">Delete</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Group memberships -->
|
|
<section class="ud-section">
|
|
<div class="ud-section-head">
|
|
<h2>Group memberships</h2>
|
|
<span class="sub">Groups this user belongs to. Add to <strong>Admin</strong> to grant full access.</span>
|
|
</div>
|
|
<div id="memberships-loading" class="ea-loading">Loading memberships…</div>
|
|
<table class="members-table" id="memberships-table" style="display:none;">
|
|
<thead><tr>
|
|
<th>Group</th><th>Origin</th>
|
|
<th style="text-align:right">Actions</th>
|
|
</tr></thead>
|
|
<tbody id="memberships-tbody"></tbody>
|
|
</table>
|
|
<div class="ea-empty" id="memberships-empty" style="display:none;">
|
|
User is not in any groups.
|
|
</div>
|
|
<div class="add-member-row">
|
|
<select id="add-group-select">
|
|
<option value="">— Pick a group —</option>
|
|
</select>
|
|
<button id="add-group-btn" disabled>Add to group</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Effective access -->
|
|
<section class="ud-section">
|
|
<div class="ud-section-head">
|
|
<h2>Effective access</h2>
|
|
<span class="sub">Resources this user can see, derived from group memberships.</span>
|
|
</div>
|
|
<div id="effective-loading" class="ea-loading">Loading…</div>
|
|
<div id="effective-content" style="display:none;"></div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
|
|
|
<script>
|
|
const USER_ID = document.querySelector("[data-user-id]").dataset.userId;
|
|
const USER_API = `/api/users/${encodeURIComponent(USER_ID)}`;
|
|
const MEMBERSHIPS_API = `/api/admin/users/${encodeURIComponent(USER_ID)}/memberships`;
|
|
const EFFECTIVE_API = `/api/admin/users/${encodeURIComponent(USER_ID)}/effective-access`;
|
|
const GROUPS_API = "/api/admin/groups";
|
|
|
|
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 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);
|
|
}
|
|
|
|
let userState = null; // /api/users/{id} response
|
|
let memberships = []; // [{group_id, group_name, is_system, source, added_at}]
|
|
let allGroups = []; // for the add-to-group dropdown
|
|
|
|
async function loadAll() {
|
|
await Promise.all([loadUser(), loadMemberships(), loadGroups(), loadEffectiveAccess()]);
|
|
}
|
|
|
|
async function loadUser() {
|
|
const r = await fetch(USER_API, { credentials: "include" });
|
|
if (r.ok) {
|
|
userState = await r.json();
|
|
renderHeader();
|
|
renderAccountStatus();
|
|
}
|
|
}
|
|
|
|
function renderHeader() {
|
|
const pill = document.getElementById("status-pill");
|
|
if (userState && !userState.active) {
|
|
pill.textContent = "Deactivated";
|
|
pill.className = "ud-status-pill inactive";
|
|
pill.style.display = "inline-block";
|
|
} else if (userState) {
|
|
pill.textContent = "Active";
|
|
pill.className = "ud-status-pill active";
|
|
pill.style.display = "inline-block";
|
|
}
|
|
const adminPill = document.getElementById("admin-pill");
|
|
const isAdmin = memberships.some(m => m.group_name === "Admin");
|
|
adminPill.style.display = isAdmin ? "inline-block" : "none";
|
|
}
|
|
|
|
function renderAccountStatus() {
|
|
const node = document.getElementById("account-status-text");
|
|
const toggleBtn = document.getElementById("toggle-active-btn");
|
|
if (!userState) { node.textContent = "—"; return; }
|
|
if (userState.active) {
|
|
node.innerHTML = `<strong>${esc(userState.email)}</strong> is active.`;
|
|
toggleBtn.textContent = "Deactivate";
|
|
} else {
|
|
node.innerHTML = `<strong>${esc(userState.email)}</strong> is deactivated. They cannot log in.`;
|
|
toggleBtn.textContent = "Reactivate";
|
|
}
|
|
}
|
|
|
|
async function loadMemberships() {
|
|
const r = await fetch(MEMBERSHIPS_API, { credentials: "include" });
|
|
document.getElementById("memberships-loading").style.display = "none";
|
|
if (!r.ok) {
|
|
toast("Failed to load memberships", "error");
|
|
return;
|
|
}
|
|
memberships = await r.json();
|
|
renderMemberships();
|
|
renderHeader(); // admin pill depends on memberships
|
|
}
|
|
|
|
function renderMemberships() {
|
|
const table = document.getElementById("memberships-table");
|
|
const empty = document.getElementById("memberships-empty");
|
|
const tbody = document.getElementById("memberships-tbody");
|
|
|
|
if (memberships.length === 0) {
|
|
table.style.display = "none";
|
|
empty.style.display = "block";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
table.style.display = "table";
|
|
tbody.innerHTML = "";
|
|
for (const m of memberships) {
|
|
const tr = document.createElement("tr");
|
|
const removable = m.source === "admin"
|
|
? `<button class="account-action-btn danger" data-action="remove" data-group-id="${esc(m.group_id)}">Remove</button>`
|
|
: `<span style="color:#9ca3af;font-size:11px">managed by ${esc(m.source)}</span>`;
|
|
// Map raw source value to a humane phrase. Keep the date inline as
|
|
// secondary metadata rather than a separate column.
|
|
const sourceLabel = ({
|
|
admin: "added by admin",
|
|
google_sync: "synced from Google",
|
|
system_seed: "system-managed",
|
|
})[m.source] || m.source;
|
|
const addedFragment = m.added_at
|
|
? `<span class="added">· ${esc(fmtDate(m.added_at))}</span>` : "";
|
|
tr.innerHTML = `
|
|
<td><a class="group-link" href="/admin/groups">${esc(m.group_name)}</a></td>
|
|
<td><span class="source-meta"><span class="label">${esc(sourceLabel)}</span>${addedFragment}</span></td>
|
|
<td style="text-align:right">${removable}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
}
|
|
tbody.querySelectorAll('[data-action="remove"]').forEach(btn => {
|
|
btn.addEventListener("click", () => removeFromGroup(btn.dataset.groupId));
|
|
});
|
|
}
|
|
|
|
async function loadGroups() {
|
|
const r = await fetch(GROUPS_API, { credentials: "include" });
|
|
if (!r.ok) return;
|
|
allGroups = await r.json();
|
|
refreshGroupDropdown();
|
|
}
|
|
|
|
function refreshGroupDropdown() {
|
|
const sel = document.getElementById("add-group-select");
|
|
const memberOf = new Set(memberships.map(m => m.group_id));
|
|
sel.innerHTML = '<option value="">— Pick a group —</option>';
|
|
for (const g of allGroups) {
|
|
if (memberOf.has(g.id)) continue; // already a member, hide
|
|
const opt = document.createElement("option");
|
|
opt.value = g.id;
|
|
opt.textContent = g.name + (g.is_system ? " (system)" : "");
|
|
sel.appendChild(opt);
|
|
}
|
|
document.getElementById("add-group-btn").disabled = sel.options.length <= 1;
|
|
}
|
|
|
|
document.getElementById("add-group-select").addEventListener("change", e => {
|
|
document.getElementById("add-group-btn").disabled = !e.target.value;
|
|
});
|
|
|
|
document.getElementById("add-group-btn").addEventListener("click", async () => {
|
|
const sel = document.getElementById("add-group-select");
|
|
const gid = sel.value;
|
|
if (!gid) return;
|
|
const r = await fetch(MEMBERSHIPS_API, {
|
|
method: "POST", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ group_id: gid }),
|
|
});
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
toast("Add failed: " + (err.detail || r.status), "error");
|
|
return;
|
|
}
|
|
toast("Added to group", "success");
|
|
await Promise.all([loadMemberships(), loadEffectiveAccess()]);
|
|
refreshGroupDropdown();
|
|
});
|
|
|
|
async function removeFromGroup(group_id) {
|
|
if (!confirm("Remove user from this group?")) return;
|
|
const r = await fetch(`${MEMBERSHIPS_API}/${encodeURIComponent(group_id)}`, {
|
|
method: "DELETE", credentials: "include",
|
|
});
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
toast("Remove failed: " + (err.detail || r.status), "error");
|
|
return;
|
|
}
|
|
toast("Removed from group", "success");
|
|
await Promise.all([loadMemberships(), loadEffectiveAccess()]);
|
|
refreshGroupDropdown();
|
|
}
|
|
|
|
async function loadEffectiveAccess() {
|
|
const r = await fetch(EFFECTIVE_API, { credentials: "include" });
|
|
document.getElementById("effective-loading").style.display = "none";
|
|
const content = document.getElementById("effective-content");
|
|
content.style.display = "block";
|
|
|
|
if (!r.ok) {
|
|
content.innerHTML = `<div class="ea-empty">Failed to load.</div>`;
|
|
return;
|
|
}
|
|
const data = await r.json();
|
|
|
|
if (data.is_admin) {
|
|
content.innerHTML = `
|
|
<div class="ea-admin-pill">
|
|
<span class="icon">🔑</span>
|
|
<span class="text">
|
|
<strong>Full access via the Admin group.</strong><br>
|
|
This user can read/write everything regardless of explicit grants.
|
|
</span>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
if (!data.items || data.items.length === 0) {
|
|
content.innerHTML = `<div class="ea-empty">User has no resource access yet. Add them to a group with grants.</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = `<table class="ea-table">
|
|
<thead><tr>
|
|
<th>Type</th><th>Resource</th><th>Granted via</th>
|
|
</tr></thead><tbody>`;
|
|
for (const it of data.items) {
|
|
const viaHtml = it.via_groups
|
|
.map(g => `<span class="via-group">${esc(g.group_name)}</span>`).join(" ");
|
|
html += `
|
|
<tr>
|
|
<td style="white-space:nowrap">${esc(it.resource_type)}</td>
|
|
<td class="ea-rid">${esc(it.resource_id)}</td>
|
|
<td class="ea-via">${viaHtml}</td>
|
|
</tr>`;
|
|
}
|
|
html += `</tbody></table>`;
|
|
content.innerHTML = html;
|
|
}
|
|
|
|
// Account actions
|
|
document.getElementById("reset-pw-btn").addEventListener("click", async () => {
|
|
if (!confirm("Send password reset link to " + (userState && userState.email) + "?")) return;
|
|
const r = await fetch(`${USER_API}/reset-password`, {
|
|
method: "POST", credentials: "include",
|
|
});
|
|
if (!r.ok) {
|
|
toast("Reset failed: " + r.status, "error");
|
|
return;
|
|
}
|
|
const data = await r.json();
|
|
toast(data.email_sent ? "Reset link emailed" : "Reset token: " + data.reset_token, "success");
|
|
});
|
|
|
|
document.getElementById("toggle-active-btn").addEventListener("click", async () => {
|
|
if (!userState) return;
|
|
const newActive = !userState.active;
|
|
const verb = newActive ? "Reactivate" : "Deactivate";
|
|
if (!confirm(`${verb} this user?`)) return;
|
|
const path = newActive ? "/activate" : "/deactivate";
|
|
const r = await fetch(`${USER_API}${path}`, { method: "POST", credentials: "include" });
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
toast(verb + " failed: " + (err.detail || r.status), "error");
|
|
return;
|
|
}
|
|
toast(verb + "d", "success");
|
|
loadUser();
|
|
});
|
|
|
|
document.getElementById("delete-user-btn").addEventListener("click", async () => {
|
|
if (!userState) return;
|
|
if (!confirm(`Delete ${userState.email}? This cannot be undone.`)) return;
|
|
const r = await fetch(USER_API, { method: "DELETE", credentials: "include" });
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
toast("Delete failed: " + (err.detail || r.status), "error");
|
|
return;
|
|
}
|
|
toast("User deleted", "success");
|
|
setTimeout(() => { window.location.href = "/admin/users"; }, 800);
|
|
});
|
|
|
|
loadAll();
|
|
</script>
|
|
{% endblock %}
|