Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 185 additions & 20 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,30 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.environment-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--warn) 8%, var(--surface));
}
.environment-notice strong,
.environment-notice p {
display: block;
margin: 0;
}
.environment-notice p {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.environment-notice button {
flex: none;
}
.governance-overview {
margin: 0 0 22px;
padding: 18px 20px;
Expand Down Expand Up @@ -3987,6 +4011,13 @@ <h1>Governance</h1>
<span>Scope</span><strong id="governance-scope-label">Organization</strong>
</div>
</div>
<aside class="environment-notice hidden" id="environment-notice" role="status">
<div>
<strong id="environment-notice-title"></strong>
<p id="environment-notice-detail"></p>
</div>
<button type="button" id="environment-notice-open">Open environment</button>
</aside>
<section class="governance-overview" id="governance-overview" aria-labelledby="governance-overview-title">
<div class="governance-overview-head">
<h2 id="governance-overview-title">Effective state</h2>
Expand Down Expand Up @@ -5717,6 +5748,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
});

let scopeDir = null;
let environmentDir = [];
let scopeDirNote = "Loading scopes…";
async function loadScopeDirectory() {
const r = await api("GET", "/api/scopes");
Expand All @@ -5728,6 +5760,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
}
viewLoadedAt.history = Date.now();
scopeDir = r.data.scopes || [];
environmentDir = r.data.environments || [];

if (SCOPED.has(view) && !urlToState().session) {
const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit");
Expand Down Expand Up @@ -6244,6 +6277,19 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
}
window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true });
function renderEnvironmentNotice(data) {
const notice = $("environment-notice");
const attachment = data?.environmentAttachment;
notice.classList.toggle("hidden", !attachment);
if (!attachment) return;
const name = attachment.environmentName || shortName(attachment.environmentId);
$("environment-notice-title").textContent = "Uses named environment " + name;
$("environment-notice-detail").textContent =
"Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here.";
$("environment-notice-open").textContent = "Open " + name;
$("environment-notice-open").onclick = () =>
go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 });
}
let governanceReq = 0;
async function loadScope() {
const requestedScope = scope;
Expand All @@ -6259,6 +6305,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
return;
}
renderEnvironmentNotice(r.data);
renderGovernanceOverview(r.data);
syncGovernanceSectionNav();
loadedCommandPolicyPresent = r.data.commandPolicy != null;
Expand Down Expand Up @@ -10707,11 +10754,14 @@ <h2 id="governance-review-title">Confirm governance change</h2>
function renderUsers(root, d) {
const users = d.users || [];
const grants = d.grants || [];
const samePrincipal = (a, b) =>
a === b || (a.includes("@") && b.includes("@") && a.toLowerCase() === b.toLowerCase());
let filterVal = "";
defaultShell({
stats: [
[users.length, "Users"],
[grants.length, "Admins"],
[users.filter((u) => u.deactivation).length, "Deactivated"],
],
search: {
placeholder: "principal, role, or scope",
Expand Down Expand Up @@ -10818,7 +10868,11 @@ <h2 id="governance-review-title">Confirm governance change</h2>
const members = await dirMembers(v);
if (members.length > 1)
return setStatus("st-imp", "Matches several people — pick one from the suggestions.", "err");
await openWebUiAs(members[0]?.principalId ?? v);
const target = members[0]?.principalId ?? v;
if (users.find((u) => samePrincipal(u.principalId, target))?.deactivation) {
return setStatus("st-imp", "Reactivate this account before impersonating it.", "err");
}
await openWebUiAs(target);
} catch (e) {
setStatus("st-imp", "Couldn't resolve that (" + (e?.message || "directory lookup failed") + ").", "err");
} finally {
Expand Down Expand Up @@ -10847,7 +10901,12 @@ <h2 id="governance-review-title">Confirm governance change</h2>
lists.textContent = "";
const filteredGrants = grants.filter((g) => matches([g.principalId, g.role, g.scopeId, g.grantedBy || ""]));
const filteredUsers = users.filter((u) =>
matches([u.principalId, u.admin?.role || "", u.admin?.scopeId || "member"]),
matches([
u.principalId,
u.admin?.role || "",
u.admin?.scopeId || "member",
u.deactivation ? "deactivated " + (u.deactivation.source || "") : "active",
]),
);

const admins = actionTable(
Expand Down Expand Up @@ -10875,47 +10934,76 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);

const roster = openableTable(
["Principal", "Role", "Last seen", "Sessions", "Turns", "", ""],
["Principal", "Role", "Access", "Last seen", "Sessions", "Turns", "", ""],
filteredUsers,
(u) => [
{ text: u.principalId, cls: "mono" },
nodeCell(mutedText(u.admin?.isAdmin ? labelRole(u.admin.role) : "member")),
nodeCell(mutedText(u.deactivation ? "deactivated" : "active", u.deactivation ? "err" : "ok")),
u.lastSeenAt ? timeCell(u.lastSeenAt) : { text: "-", cls: "num" },
{ text: String(u.sessionCount), cls: "num" },
{ text: String(u.turnCount), cls: "num" },
{
action: {
label: "Impersonate ↗",
disabled: !!u.deactivation,
title: u.deactivation ? "Reactivate this account before impersonating it." : "",
run: (ev) => {
ev?.stopPropagation?.();
openWebUiAs(u.principalId);
},
},
},
u.admin?.isAdmin
? { text: "" }
: {
u.deactivation
? {
action: {
label: "Make admin",
run: (ev) => {
label: "Reactivate",
run: async (ev) => {
ev?.stopPropagation?.();
pInput.value = u.principalId;
pInput.focus();
form.scrollIntoView({ block: "center" });
const button = ev?.currentTarget;
if (button) button.disabled = true;
try {
const r = await api(
"POST",
"/api/users/" + encodeURIComponent(u.principalId) + "/reactivate",
);
if (r.ok) renderData();
else setStatus("st-user-access-list", r.data?.message || "Reactivation failed.", "err");
} catch (e) {
setStatus("st-user-access-list", e?.message || "Reactivation failed.", "err");
} finally {
if (button?.isConnected) button.disabled = false;
}
},
},
}
: u.admin?.isAdmin
? { text: "" }
: {
action: {
label: "Make admin",
run: (ev) => {
ev?.stopPropagation?.();
pInput.value = u.principalId;
pInput.focus();
form.scrollIntoView({ block: "center" });
},
},
},
},
],
(u) => go({ view: "user", scope, session: null, principal: u.principalId }),
"No users match.",
);
lists.appendChild(
dataCard(
"Users",
"Everyone who has used the agent — click a row for their activity, artifacts, and config.",
roster,
),
const usersCard = dataCard(
"Users",
"Everyone who has used the agent — click a row for their activity, artifacts, and config.",
roster,
);
const usersStatus = document.createElement("p");
usersStatus.className = "status";
usersStatus.id = "st-user-access-list";
usersCard.querySelector(".body").appendChild(usersStatus);
lists.appendChild(usersCard);
};
drawLists();
}
Expand Down Expand Up @@ -11451,6 +11539,21 @@ <h2 id="governance-review-title">Confirm governance change</h2>
actions: [sortControl],
});
const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0);
if (environmentDir.length) {
const environments = denseList(
environmentDir,
(environment) => ({
name: environment.name || shortName(environment.id),
preview: plural(environment.attachedScopes?.length || 0, "attached scope"),
href: stateToUrl({ view: "history", scope: environment.id, historyKind }),
}),
(environment) => selectScope(environment.id),
"No named environments.",
);
root.appendChild(
dataCard("Named environments", "Named computers and working memory that scopes can share.", environments),
);
}
const t = denseList(
activeRows,
(s) => {
Expand Down Expand Up @@ -11703,13 +11806,75 @@ <h2 id="governance-review-title">Confirm governance change</h2>
pageShell({
back: { label: "← Users", onClick: () => history.back() },
title: d.displayName || d.principalId || principalId,
context: [d.admin?.isAdmin ? labelRole(d.admin.role) : "member", d.scopeId || ""].filter(Boolean).join(" · "),
context: [
d.admin?.isAdmin ? labelRole(d.admin.role) : "member",
d.deactivation ? "deactivated" : "active",
d.scopeId || "",
]
.filter(Boolean)
.join(" · "),
});
const userActions = document.createElement("div");
userActions.style.margin = "0 0 14px";
userActions.appendChild(webUiAsButton(d.principalId || principalId));
const impersonate = webUiAsButton(d.principalId || principalId);
if (d.deactivation) {
impersonate.disabled = true;
impersonate.title = "Reactivate this account before impersonating it.";
}
userActions.appendChild(impersonate);
if (d.deactivation) {
const reactivate = document.createElement("button");
reactivate.type = "button";
reactivate.className = "primary";
reactivate.style.marginLeft = "8px";
reactivate.textContent = "Reactivate account";
reactivate.onclick = async () => {
reactivate.disabled = true;
try {
const response = await api(
"POST",
"/api/users/" + encodeURIComponent(d.principalId || principalId) + "/reactivate",
);
if (response.ok) return void showUserDetail(principalId);
setStatus("st-user-access", response.data?.message || "Reactivation failed.", "err");
} catch (e) {
setStatus("st-user-access", e?.message || "Reactivation failed.", "err");
} finally {
reactivate.disabled = false;
}
};
userActions.appendChild(reactivate);
}
detail.appendChild(userActions);

const accessState = document.createElement("div");
accessState.appendChild(
table(
["State", "Source", "Changed"],
[
[
nodeCell(mutedText(d.deactivation ? "Deactivated" : "Active", d.deactivation ? "err" : "ok")),
d.deactivation?.source || "—",
d.deactivation?.at ? fmtTime(d.deactivation.at) : "—",
],
],
"",
),
);
const accessStatus = document.createElement("p");
accessStatus.className = "status";
accessStatus.id = "st-user-access";
accessState.appendChild(accessStatus);
detail.appendChild(
dataCard(
"Account access",
d.deactivation
? "This identity cannot use QM until an org admin reactivates it."
: "This identity can use QM.",
accessState,
),
);

const stats = d.stats || {};
detail.appendChild(
kpis([
Expand Down
18 changes: 18 additions & 0 deletions plugins/admin/test/account-access-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");

test("the users surface shows deactivation state and offers recovery", () => {
assert.match(html, /users\.filter\(\(u\) => u\.deactivation\)\.length, "Deactivated"/);
assert.match(html, /label: "Reactivate"/);
assert.match(html, /"Account access"/);
assert.match(html, /"Reactivate account"/);
assert.match(html, /impersonate\.disabled = true/);
assert.match(html, /Reactivate this account before impersonating it/);
assert.match(html, /st-user-access-list/);
assert.match(html, /catch \(e\)[\s\S]*Reactivation failed/);
assert.match(html, /finally \{[\s\S]*reactivate\.disabled = false/);
assert.match(html, /\/reactivate/);
});
13 changes: 13 additions & 0 deletions plugins/admin/test/environments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8");

test("the admin UI lists named environments and links attachment warnings", () => {
assert.match(html, /Named environments/);
assert.match(html, /id="environment-notice"/);
assert.match(html, /Uses named environment/);
assert.match(html, /scope: attachment\.environmentId/);
});
13 changes: 13 additions & 0 deletions plugins/admin/test/grants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ test("GET /api/users forwards to /v1/admin/users", async () => {
assert.equal(c.actor, "U-admin@acme");
});

test("POST /api/users/:id/reactivate forwards the recovery request", async () => {
const r = await fetch(`${base}/api/users/locked%40example.com/reactivate`, {
method: "POST",
headers: { cookie: ADMIN },
});
assert.equal(r.status, 200);
const c = calls.at(-1)!;
assert.equal(c.method, "POST");
assert.equal(c.url, "/v1/admin/users/locked%40example.com/reactivate");
assert.equal(c.actor, "U-admin@acme");
assert.equal(c.signed, true);
});

test("GET /api/keychain forwards to /v1/admin/keychain", async () => {
const r = await fetch(`${base}/api/keychain`, { headers: { cookie: ADMIN } });
assert.equal(r.status, 200);
Expand Down
Loading