CI / test (push) Successful in 1m35s
A quota could be enforced and reported, and set nowhere but a shell. Nothing in the browser showed one at all — not what a team may spend, not what it has spent, not that it had no limit. In practice that means quotas nobody sets. /admin/quotas lists the whole tree, whether or not a group has a limit: the one without a quota is the one most likely to need one, so it cannot be the row the page leaves out. Spend over the last thirty days sits beside the budget, with the fraction used. Setting a quota here is a change of position. It was kept to the shell because raising a budget is a financial act and a stolen admin session should not perform one. That argument holds for exactly one row on this page: resolution takes the most restrictive value along the chain, so raising a team's or a project's limit cannot exceed what the organization allows. Only an organization's own limit is bounded by nothing. Every change is logged with who made it and what it was before. Per-key limits stay on the CLI. A key belongs to a person and is already bounded by its project; letting an admin session reach individual keys would put every caller's throughput one click away, and the page refuses a key scope rather than rendering it and failing later. A limit an ancestor already caps lower is marked as written-but-unreachable. Resolution means a project given more than its team is not a bigger budget, it is a number nobody will reach — and without saying so the page would show it as though it applied, with the first sign being a refusal that disagrees with the screen. The group is chosen, not typed. Ids became random in the previous commit so two organizations could both have a "platform" team, which also made them impossible to remember and a typo impossible to notice: it would write a quota onto nothing and report success. A blank field is no limit of that kind, not zero — zero is a limit of nothing, which refuses every request. Clearing removes the row rather than writing nulls across it: both resolve the same, only one tells a reader nobody has set a limit here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
5.1 KiB
HTML
123 lines
5.1 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>Accounts · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
|
</head><body><div class="wrap">
|
|
<header>
|
|
<h1>Accounts</h1>
|
|
<p class="sub">{{len .Pending}} waiting for approval · {{len .Active}} active</p>
|
|
<nav><a href="/admin">cluster</a>
|
|
{{if .HasMirror}} <a href="/admin/mirror">mirror</a>
|
|
{{end}}{{if .HasQuotas}} <a href="/admin/quotas">quotas</a>
|
|
{{end}} <a href="/profile">your keys</a></nav>
|
|
</header>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
|
|
|
|
<h2>Waiting for approval</h2>
|
|
{{if .Pending}}
|
|
<table><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Project</th><th></th></tr>
|
|
{{range .Pending}}<tr>
|
|
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
|
|
<td colspan="2">
|
|
<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<select name="project">
|
|
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
|
|
</select>
|
|
<button type="submit">Approve</button>
|
|
</form>
|
|
<form method="post" action="/admin/users/{{.ID}}/suspend" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">Reject</button>
|
|
</form>
|
|
</td>
|
|
</tr>{{end}}</table>
|
|
{{else}}<p class="empty">Nobody is waiting.</p>{{end}}
|
|
|
|
<h2>Accounts</h2>
|
|
{{if .Active}}
|
|
<table><tr><th>Email</th><th>State</th><th>Admin</th><th>Project</th><th>Last login</th><th></th></tr>
|
|
{{range .Active}}<tr>
|
|
<td>{{.Email}}</td>
|
|
<td class="state state-{{.State}}">{{.State}}</td>
|
|
<td>{{if .IsAdmin}}yes{{else}}—{{end}}</td>
|
|
<td>{{or .ProjectID "—"}}</td>
|
|
<td>{{ago .LastLoginAt}}</td>
|
|
<td>
|
|
{{if eq (printf "%s" .State) "active"}}
|
|
<form method="post" action="/admin/users/{{.ID}}/suspend" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">Suspend</button>
|
|
</form>
|
|
{{else}}
|
|
<form method="post" action="/admin/users/{{.ID}}/reinstate" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link">Reinstate</button>
|
|
</form>
|
|
{{end}}
|
|
</td>
|
|
</tr>{{end}}</table>
|
|
{{else}}<p class="empty">No accounts yet.</p>{{end}}
|
|
|
|
<h2>Organizations, teams and projects</h2>
|
|
<p class="note">Where an account lives. A person is approved into a project, a
|
|
project belongs to a team, and a team to an organization — and a quota can be
|
|
written on any of the three, so the shape here is the shape the bill takes.</p>
|
|
|
|
<div class="field-row">
|
|
<form method="post" action="/admin/tenancy/org">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>New organization
|
|
<input type="text" name="name" required placeholder="Scania"></label>
|
|
<button type="submit">Create</button>
|
|
</form>
|
|
|
|
<form method="post" action="/admin/tenancy/team">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>New team
|
|
<input type="text" name="name" required placeholder="platform"></label>
|
|
<label>in
|
|
<select name="org" required>
|
|
{{range .Orgs}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
|
</select></label>
|
|
<button type="submit"{{if not .Orgs}} disabled{{end}}>Create</button>
|
|
</form>
|
|
|
|
<form method="post" action="/admin/tenancy/project">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>New project
|
|
<input type="text" name="name" required placeholder="gateway"></label>
|
|
<label>in
|
|
<select name="team" required>
|
|
{{range .Teams}}<option value="{{.ID}}">{{.OrgName}} / {{.Name}}</option>{{end}}
|
|
</select></label>
|
|
<button type="submit"{{if not .Teams}} disabled{{end}}>Create</button>
|
|
</form>
|
|
</div>
|
|
|
|
{{if not .Orgs}}
|
|
<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>
|
|
{{else}}
|
|
{{/* One path column, not three: the store returns the chain already joined,
|
|
which is the string the approval chooser shows, and three headers over one
|
|
value line up with nothing. */}}
|
|
<table><tr><th>Organization / team / project</th><th>ID</th></tr>
|
|
{{range .Projects}}<tr><td>{{.Path}}</td><td><code>{{.ID}}</code></td></tr>{{end}}
|
|
{{if not .Projects}}<tr><td colspan="2" class="empty">No projects yet, so nobody can be approved.</td></tr>{{end}}
|
|
</table>
|
|
{{end}}
|
|
|
|
<p class="note">Four things are deliberately not here. Making someone an admin,
|
|
resetting a password, issuing keys on another
|
|
person's behalf and setting any quota all need a shell on the control plane — <code>llmgw users promote</code>,
|
|
<code>reset-password</code>, <code>llmgw keys</code>, <code>llmgw keys limits</code>.
|
|
A stolen admin session can approve, suspend and make somewhere to put people; it
|
|
cannot hand itself more access, take over an account, or raise what anybody is
|
|
allowed to spend.</p>
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
|
</div></body></html>{{end}}
|