Files
llmgw/internal/web/templates/admin_users.html
T
Ilker ManapandClaude Opus 5.5 e493b2f53d Give an organization's managers a page of their own
A member of an organization can be its manager (users join --org X
--role manager, or the star on the people page). /org shows a manager
their organization: usage over time by team, project or person, its
teams and projects with limits and 30-day spend, and its members, each
once with every place they belong. Read-only; administrators see every
organization. Only what was spent in the organization's projects is
shown — UsageBucketsIn filters by where a request was spent.

Found on the way:
- Usage by org, team and project, and the quotas page's spend, joined
  through api_keys and so dropped every chat-page request, which has no
  key row. They now read usage.project_id; keys no longer move between
  projects, so the two agree.
- /org was not exempt from API-key auth and answered browsers with a
  JSON error. A router test now drives the session pages through the
  middleware; it fails without the fix.

Membership queries, org-scoped usage and the reworked joins pass the
PostgreSQL store tests on 16.

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

360 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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>Approve, and put them in</th><th></th></tr>
{{range .Pending}}<tr>
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
<td>
<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}}">
<option value="">No project yet: on their own</option>
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
</select>
<button type="submit">Approve</button>
</form>
</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>
{{template "scope-members" (dict "Kind" "org" "ID" $org.ID "Name" $org.Name "D" $)}}
<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>
{{template "scope-members" (dict "Kind" "team" "ID" $team.ID "Name" $team.Name "D" $)}}
<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}} member{{if ne $p.Users 1}}s{{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 $.Members (printf "project:%s" $p.ID)}}
<table class="compact"><tr><th>Member</th><th>State</th><th>Last sign-in</th><th></th></tr>
{{range .}}{{template "member-row" (dict "U" . "D" $ "Scope" (printf "project:%s" $p.ID) "Open" $p.ID)}}{{end}}
</table>
{{else}}<p class="empty">No members of its own. Members of its team or organization can use it too.</p>{{end}}
{{template "add-member" (dict "Kind" "project" "ID" $p.ID "D" $ "Open" $p.ID)}}
{{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>Member of</th><th>Last sign-in</th><th>Add to</th><th></th></tr>
{{range .Accounts}}{{template "account-row" (dict "U" . "D" $)}}{{end}}
</table>
<p class="note">Someone who is a member of nothing uses the gateway on their own:
personal keys and the chat page, within their personal limits.</p>
{{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}}
{{/* The actions every account row carries: suspend or reinstate, remove. */}}
{{define "state-actions"}}{{$u := .U}}{{$d := .D}}
{{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}}
{{end}}
{{/* A person in the list of every account: where they are, and where else
they could be. */}}
{{define "account-row"}}{{$u := .U}}{{$d := .D}}
<tr data-q="{{lower $u.Email}} {{lower $u.Name}}{{range $u.Memberships}} {{lower .Path}}{{end}}">
<td>{{$u.Email}}{{if $u.IsAdmin}} <span class="tag">admin</span>{{end}}</td>
<td class="state state-{{$u.State}}">{{$u.State}}</td>
<td class="where">
{{range $u.Memberships}}<span class="chip-m">{{.Path}}{{if eq .Role "manager"}} <b class="chip-role">manager</b>{{end}}
{{if eq (printf "%s" .Scope) "org"}}
<form method="post" action="/admin/users/{{$u.ID}}/role" class="inline">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="org:{{.ScopeID}}">
<input type="hidden" name="role" value="{{if eq .Role "manager"}}member{{else}}manager{{end}}">
<button type="submit" class="chip-x" title="{{if eq .Role "manager"}}Stop managing {{.Path}}{{else}}Make manager of {{.Path}}: can see its page{{end}}"
aria-label="{{if eq .Role "manager"}}Stop managing{{else}}Make manager{{end}}">{{if eq .Role "manager"}}☆{{else}}★{{end}}</button>
</form>
{{end}}
<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
hx-confirm="Take {{$u.Email}} out of {{.Path}}? Keys they made there are revoked.">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}:{{.ScopeID}}">
<button type="submit" class="chip-x" aria-label="Take out of {{.Path}}" title="Take out of {{.Path}}">×</button>
</form></span>
{{else}}<span class="opt">on their own</span>{{end}}
</td>
<td>{{ago $u.LastLoginAt}}</td>
<td>
<form method="post" action="/admin/users/{{$u.ID}}/join" class="inline">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<select name="scope" aria-label="Add {{$u.Email}} to" required>
<option value="">choose…</option>
{{range $d.Scopes}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<button type="submit" class="small">Add</button>
</form>
</td>
<td class="actions">{{template "state-actions" (dict "U" $u "D" $d "Open" "")}}</td>
</tr>{{end}}
{{/* A member in a scope's own list: taking them out of here is the action. */}}
{{define "member-row"}}{{$u := .U}}{{$d := .D}}
<tr>
<td>{{$u.Email}}{{if $u.IsAdmin}} <span class="tag">admin</span>{{end}}
{{if gt (len $u.Memberships) 1}}<span class="opt">also in {{sub (len $u.Memberships) 1}} more</span>{{end}}</td>
<td class="state state-{{$u.State}}">{{$u.State}}</td>
<td>{{ago $u.LastLoginAt}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
hx-confirm="Take {{$u.Email}} out of here? Keys they made here are revoked.">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="open" value="{{.Open}}">
<button type="submit" class="link danger">take out</button>
</form>
</td>
</tr>{{end}}
{{/* Adding a person to a scope, from the scope. */}}
{{define "add-member"}}{{$d := .D}}
<form method="post" action="/admin/tenancy/{{.Kind}}/{{.ID}}/add" class="add">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="open" value="{{.Open}}">
<select name="user" aria-label="Person to add" required>
<option value="">choose a person…</option>
{{range $d.Accounts}}<option value="{{.ID}}">{{.Email}}</option>{{end}}
</select>
<button type="submit">Add member</button>
</form>{{end}}
{{/* An org's or team's own members, folded away until asked for. */}}
{{define "scope-members"}}{{$d := .D}}{{$k := printf "%s:%s" .Kind .ID}}
<details class="edit"><summary>{{len (index $d.Members $k)}} member{{if ne (len (index $d.Members $k)) 1}}s{{end}}</summary>
<div class="scope-members">
{{with index $d.Members $k}}
<table class="compact">
{{range .}}{{template "member-row" (dict "U" . "D" $d "Scope" $k "Open" "")}}{{end}}
</table>
{{else}}<p class="note">Nobody is a member of {{.Name}} itself; a member here could use every project in it.</p>{{end}}
{{template "add-member" (dict "Kind" .Kind "ID" .ID "D" $d "Open" "")}}
</div>
</details>{{end}}