Files
llmgw/internal/web/templates/admin_users.html
T
Ilker ManapandClaude Opus 5.5 235bf33dcd
CI / test (push) Failing after 31s
Release / build (push) Successful in 1m4s
Release / notes (push) Successful in 4s
Keep every admin table to one line per record
Headers never wrap, free text is clipped with the whole on hover, full-width
rows are only notes and forms. The cluster page hides columns empty on every
row and shortens node names; People shows two memberships and a count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:02:18 +02:00

379 lines
18 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="{{stylesheet}}">
{{/* 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 class="rows"><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="rows 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="rows 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}}
{{/* Folded until wanted: a box open under every team was most of the
tree's height and read as something to fill in. */}}
<details class="add-inline">
<summary>+ project</summary>
<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>
</details>
</div>
{{end}}
<details class="add-inline team-add">
<summary>+ team</summary>
<form method="post" action="/admin/tenancy/team" class="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>
</details>
</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 class="rows" 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}}
{{/* In a menu: two red links on every row made the list look alarmed,
and they are the two things done least often. */}}
<details class="row-menu">
<summary aria-label="More for {{$u.Email}}" title="More">⋯</summary>
<div class="menu">
{{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>
</div>
</details>
{{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 $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}<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>{{end}}{{end}}
{{else}}<span class="opt">on their own</span>{{end}}
{{/* Two, then a count: a person in six teams was six chips wrapping the
row into a paragraph. The rest are named on hover and in their own
scopes' member lists, where taking them out is done. */}}
{{if gt (len $u.Memberships) 2}}<span class="opt more" title="{{range $i, $m := $u.Memberships}}{{if ge $i 2}}{{$m.Path}}&#10;{{end}}{{end}}">+{{sub (len $u.Memberships) 2}}</span>{{end}}
</td>
<td>{{ago $u.LastLoginAt}}</td>
<td>
<form method="post" action="/admin/users/{{$u.ID}}/join" class="inline add-to">
<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="rows 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}}