Files
llmgw/internal/web/templates/admin_users.html
T
Ilker ManapandClaude Opus 5.5 c9cd25efc2 Draw one menu on every page
Each page wrote its own nav: the same links in a different order, some
missing, the current page sometimes listed and sometimes not. A shared
"nav" template now draws it everywhere — Cluster, People, Quotas, Usage,
Mirror for an administrator, then Chat, Your keys and Sign out — with
the current page marked by aria-current. Pages rendered outside the
account pages (the dashboard, node logs) get it through the request
context their admin wrapper sets. The admin listener's own page keeps
its metrics/pprof/api links.

The chat and key pages said "project proj-3kq7…"; they now show the
project's path, and the key page no longer repeats the email.

A test renders several admin pages and requires the same menu on each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:55:36 +02:00

290 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>
{{template "nav" .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}}