Nothing in the tree could be renamed, moved or removed, and an approved person stayed in the project they were first put in — from the web or the CLI. Now: - /admin/users is the tree, each project opening onto its people and keys, with approve, move, suspend, reinstate and remove for people, revoke for keys, and add, rename, move and remove at every level, plus a search over every account. Forms post and get the page back, so it works with no script; htmx 2.0.11 (0BSD, embedded, checked against npm's integrity hash) swaps only the changed part, keeping open projects open. - llmgw org tree/add/rename/move/remove, users move/delete, and keys list --all. - Projects, teams and organizations are named by id, path (Acme/platform/gateway) or unique name; users by id or email. With a single project --project can be left out, instead of defaulting to "default", which a tree built from the web does not have. Moving a person moves the keys they made; operator keys stay with the project. Only empty things are removed, and never a project that had a key, since usage history refers to it. Also: keys init no longer reparents an existing team or project; reinstating an account with no project is refused (a rejected signup used to come back active and fail at its first key); rejecting a signup removes it; keys limits --budget takes whole units like the quotas page. Checked in headless Chrome: a move from an open project updates the page in place, keeps the project open, and the search still filters. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
294 lines
13 KiB
HTML
294 lines
13 KiB
HTML
{{define "admin_users"}}<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>People · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
|
{{/* Forms here post and get the whole page back, which works with no script at
|
|
all. htmx, when it loads, swaps only #people from that page instead of
|
|
reloading it, so an open project stays open and the page does not jump. */}}
|
|
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false}'>
|
|
<script src="/_static/htmx-2.0.11.min.js" defer></script>
|
|
</head><body><div class="wrap">
|
|
<header>
|
|
<h1>People</h1>
|
|
<p class="sub">{{len .Pending}} waiting for approval · {{len .Accounts}} accounts · {{len .Projects}} projects</p>
|
|
<nav><a href="/admin">cluster</a>
|
|
{{if .HasMirror}} <a href="/admin/mirror">mirror</a>
|
|
{{end}}{{if .HasQuotas}} <a href="/admin/quotas">quotas</a>
|
|
<a href="/admin/usage">usage</a>
|
|
{{end}} <a href="/profile">your keys</a></nav>
|
|
</header>
|
|
|
|
<div id="people" hx-boost="true" hx-target="#people" hx-select="#people"
|
|
hx-swap="outerHTML" hx-push-url="false">
|
|
|
|
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
|
{{if .Message}}<p class="ok" role="status">{{.Message}}</p>{{end}}
|
|
|
|
{{if .Pending}}
|
|
<h2>Waiting for approval</h2>
|
|
<table><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Put them in</th><th></th></tr>
|
|
{{range .Pending}}<tr>
|
|
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
|
|
<td>
|
|
{{if $.Projects}}
|
|
<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<select name="project" aria-label="Project for {{.Email}}">
|
|
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
|
|
</select>
|
|
<button type="submit">Approve</button>
|
|
</form>
|
|
{{else}}<span class="note">Create a project first, below.</span>{{end}}
|
|
</td>
|
|
<td>
|
|
<form method="post" action="/admin/users/{{.ID}}/delete" class="inline"
|
|
hx-confirm="Reject and remove {{.Email}}?">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">Reject</button>
|
|
</form>
|
|
</td>
|
|
</tr>{{end}}</table>
|
|
{{end}}
|
|
|
|
<h2>Organizations, teams and projects</h2>
|
|
<p class="note">A person is in a project, a project in a team, a team in an
|
|
organization{{if .HasQuotas}}, and <a href="/admin/quotas">quotas</a> can be set
|
|
on any of them{{end}}. Open a project to see its people and keys.</p>
|
|
|
|
{{if not .Tree}}
|
|
<p class="empty">No organizations yet. Create one, then a team in it, then a
|
|
project in the team — an account can only be approved into a project.</p>
|
|
{{end}}
|
|
|
|
{{range $org := .Tree}}
|
|
<section class="org">
|
|
<div class="node-head">
|
|
<strong class="node-name">{{$org.Name}}</strong>
|
|
<span class="node-meta">{{len $org.Teams}} team{{if ne (len $org.Teams) 1}}s{{end}}</span>
|
|
<details class="edit"><summary>rename</summary>
|
|
<form method="post" action="/admin/tenancy/org/{{$org.ID}}/rename">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="text" name="name" value="{{$org.Name}}" required aria-label="New name">
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</details>
|
|
{{if not $org.Teams}}
|
|
<form method="post" action="/admin/tenancy/org/{{$org.ID}}/delete" class="inline"
|
|
hx-confirm="Remove the organization {{$org.Name}}?">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">remove</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{range $team := $org.Teams}}
|
|
<div class="team">
|
|
<div class="node-head">
|
|
<span class="node-name">{{$team.Name}}</span>
|
|
<span class="node-meta">{{len $team.Projects}} project{{if ne (len $team.Projects) 1}}s{{end}}</span>
|
|
<details class="edit"><summary>rename</summary>
|
|
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/rename">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="text" name="name" value="{{$team.Name}}" required aria-label="New name">
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</details>
|
|
{{if gt (len $.Orgs) 1}}
|
|
<details class="edit"><summary>move</summary>
|
|
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/move">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<select name="to" aria-label="Organization to move {{$team.Name}} to">
|
|
{{range $.Orgs}}{{if ne .ID $org.ID}}<option value="{{.ID}}">{{.Name}}</option>{{end}}{{end}}
|
|
</select>
|
|
<button type="submit">Move</button>
|
|
</form>
|
|
</details>
|
|
{{end}}
|
|
{{if not $team.Projects}}
|
|
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/delete" class="inline"
|
|
hx-confirm="Remove the team {{$team.Name}}?">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">remove</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{range $p := $team.Projects}}
|
|
<details class="project" id="p-{{$p.ID}}"{{if eq $.Open $p.ID}} open{{end}}>
|
|
<summary>
|
|
<span class="node-name">{{$p.Name}}</span>
|
|
<span class="node-meta">{{$p.Users}} {{if eq $p.Users 1}}person{{else}}people{{end}}
|
|
· {{$p.ActiveKeys}} key{{if ne $p.ActiveKeys 1}}s{{end}}{{if gt $p.Keys $p.ActiveKeys}} ({{sub $p.Keys $p.ActiveKeys}} revoked){{end}}</span>
|
|
</summary>
|
|
<div class="project-body">
|
|
<div class="node-head">
|
|
<details class="edit"><summary>rename</summary>
|
|
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/rename">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="open" value="{{$p.ID}}">
|
|
<input type="text" name="name" value="{{$p.Name}}" required aria-label="New name">
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</details>
|
|
{{if gt (len $.Teams) 1}}
|
|
<details class="edit"><summary>move to another team</summary>
|
|
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/move">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="open" value="{{$p.ID}}">
|
|
<select name="to" aria-label="Team to move {{$p.Name}} to">
|
|
{{range $.Teams}}{{if ne .ID $team.ID}}<option value="{{.ID}}">{{.OrgName}} / {{.Name}}</option>{{end}}{{end}}
|
|
</select>
|
|
<button type="submit">Move</button>
|
|
</form>
|
|
</details>
|
|
{{end}}
|
|
{{if and (eq $p.Users 0) (eq $p.Keys 0)}}
|
|
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/delete" class="inline"
|
|
hx-confirm="Remove the project {{$p.Name}}?">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">remove</button>
|
|
</form>
|
|
{{else}}<span class="note">Removable once it has no people and never had a key.</span>{{end}}
|
|
</div>
|
|
|
|
{{with index $.People $p.ID}}
|
|
<table class="compact"><tr><th>Person</th><th>State</th><th>Last sign-in</th><th>Move to</th><th></th></tr>
|
|
{{range .}}{{template "user-row-actions" (dict "U" . "D" $ "Open" $p.ID)}}{{end}}
|
|
</table>
|
|
{{else}}<p class="empty">Nobody is in this project.</p>{{end}}
|
|
|
|
{{with index $.Keys $p.ID}}
|
|
<table class="compact"><tr><th>Key</th><th>Owner</th><th>State</th><th>Last used</th><th></th></tr>
|
|
{{range .}}<tr>
|
|
<td>{{.Name}} <code>{{.Prefix}}</code></td>
|
|
<td>{{or .OwnerEmail "issued by an operator"}}</td>
|
|
<td class="state state-{{.State}}">{{.State}}</td>
|
|
<td>{{ago .LastUsedAt}}</td>
|
|
<td>{{if eq .State "active"}}
|
|
<form method="post" action="/admin/keys/{{.ID}}/revoke" class="inline"
|
|
hx-confirm="Revoke the key {{.Name}}? Whatever uses it stops working.">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="open" value="{{$p.ID}}">
|
|
<button type="submit" class="link danger">revoke</button>
|
|
</form>{{end}}</td>
|
|
</tr>{{end}}
|
|
</table>
|
|
{{end}}
|
|
</div>
|
|
</details>
|
|
{{end}}
|
|
|
|
<form method="post" action="/admin/tenancy/project" class="add">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="team" value="{{$team.ID}}">
|
|
<input type="text" name="name" required placeholder="new project in {{$team.Name}}" aria-label="New project in {{$team.Name}}">
|
|
<button type="submit">Add</button>
|
|
</form>
|
|
</div>
|
|
{{end}}
|
|
|
|
<form method="post" action="/admin/tenancy/team" class="add team-add">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="org" value="{{$org.ID}}">
|
|
<input type="text" name="name" required placeholder="new team in {{$org.Name}}" aria-label="New team in {{$org.Name}}">
|
|
<button type="submit">Add</button>
|
|
</form>
|
|
</section>
|
|
{{end}}
|
|
|
|
<form method="post" action="/admin/tenancy/org" class="add org-add">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="text" name="name" required placeholder="new organization" aria-label="New organization">
|
|
<button type="submit">Add organization</button>
|
|
</form>
|
|
|
|
<h2 id="accounts">All accounts</h2>
|
|
{{if .Accounts}}
|
|
<table id="account-list"><tr><th>Person</th><th>State</th><th>Project</th><th>Last sign-in</th><th>Move to</th><th></th></tr>
|
|
{{range .Accounts}}{{template "user-row-actions" (dict "U" . "D" $ "Open" "" "Path" true)}}{{end}}
|
|
</table>
|
|
{{else}}<p class="empty">No accounts yet.</p>{{end}}
|
|
|
|
<p class="note">Not here on purpose: making someone an admin, resetting a password
|
|
and issuing a key for someone else. Each turns a stolen admin session into more
|
|
access than it had, so they need a shell on the control plane —
|
|
<code>llmgw users promote</code>, <code>llmgw users reset-password</code>,
|
|
<code>llmgw keys create</code>.</p>
|
|
</div>
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
|
</div>
|
|
<script>
|
|
// Search the account list. Outside #people so what was typed survives a swap,
|
|
// and applied again after every swap.
|
|
(function () {
|
|
var box = document.createElement('input');
|
|
box.type = 'search'; box.placeholder = 'Find a person by email, name or project';
|
|
box.className = 'search'; box.setAttribute('aria-label', 'Find a person');
|
|
function apply() {
|
|
var q = box.value.trim().toLowerCase();
|
|
document.querySelectorAll('#account-list tr[data-q]').forEach(function (tr) {
|
|
tr.hidden = q !== '' && tr.dataset.q.indexOf(q) < 0;
|
|
});
|
|
}
|
|
box.addEventListener('input', apply);
|
|
function place() {
|
|
var h = document.getElementById('accounts');
|
|
if (h && box.parentNode !== h.parentNode) h.after(box);
|
|
apply();
|
|
}
|
|
place();
|
|
// Projects the reader had open stay open across a change.
|
|
var open = [];
|
|
document.addEventListener('htmx:beforeSwap', function () {
|
|
open = Array.prototype.map.call(document.querySelectorAll('details.project[open]'), function (d) { return d.id; });
|
|
});
|
|
document.addEventListener('htmx:afterSettle', function () {
|
|
open.forEach(function (id) { var d = document.getElementById(id); if (d) d.open = true; });
|
|
place();
|
|
});
|
|
})();
|
|
</script>
|
|
</body></html>{{end}}
|
|
|
|
{{define "user-row-actions"}}{{$u := .U}}{{$d := .D}}
|
|
<tr data-q="{{lower $u.Email}} {{lower $u.Name}} {{lower $u.ProjectPath}}">
|
|
<td>{{$u.Email}}{{if $u.IsAdmin}} <span class="tag">admin</span>{{end}}</td>
|
|
<td class="state state-{{$u.State}}">{{$u.State}}</td>
|
|
{{if .Path}}<td>{{or $u.ProjectPath "—"}}</td>{{end}}
|
|
<td>{{ago $u.LastLoginAt}}</td>
|
|
<td>
|
|
<form method="post" action="/admin/users/{{$u.ID}}/move" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
|
<input type="hidden" name="open" value="{{.Open}}">
|
|
<select name="project" aria-label="Project for {{$u.Email}}">
|
|
{{range $d.Projects}}<option value="{{.ID}}"{{if eq .ID $u.ProjectID}} selected{{end}}>{{.Path}}</option>{{end}}
|
|
</select>
|
|
<button type="submit" class="small">Move</button>
|
|
</form>
|
|
</td>
|
|
<td class="actions">
|
|
{{if ne $u.ID $d.AdminID}}
|
|
{{if eq (printf "%s" $u.State) "active"}}
|
|
<form method="post" action="/admin/users/{{$u.ID}}/suspend" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
|
<input type="hidden" name="open" value="{{.Open}}">
|
|
<button type="submit" class="link danger">suspend</button>
|
|
</form>
|
|
{{else}}
|
|
<form method="post" action="/admin/users/{{$u.ID}}/reinstate" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
|
<input type="hidden" name="open" value="{{.Open}}">
|
|
<button type="submit" class="link">reinstate</button>
|
|
</form>
|
|
{{end}}
|
|
<form method="post" action="/admin/users/{{$u.ID}}/delete" class="inline"
|
|
hx-confirm="Remove {{$u.Email}} for good? Their keys are revoked.">
|
|
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
|
<input type="hidden" name="open" value="{{.Open}}">
|
|
<button type="submit" class="link danger">remove</button>
|
|
</form>
|
|
{{else}}<span class="note">you</span>{{end}}
|
|
</td>
|
|
</tr>{{end}}
|