An account's "add to" picker and its button sit on one line; suspend and remove, the two things done least and the two drawn in red on every row, are in a menu behind "⋯"; and the box for a new project under every team, and for a new team under every organization, is folded behind "+ project" and "+ team" until wanted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
375 lines
18 KiB
HTML
375 lines
18 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="{{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><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}}
|
||
|
||
{{/* 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 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 $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 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="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}}
|