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>
290 lines
13 KiB
HTML
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}}
|