Files
llmgw/internal/web/templates/admin_users.html
T
Ilker ManapandClaude Opus 5.5 102ca9556f An organization's admin, and forms only for those who may send them
C1. A membership of an organization may be admin as well as member and
manager. An admin sees /org as a manager does and changes the
organization from it: its teams and projects, who of the approved
accounts is in them, its members' roles, and the keys spent in its
projects. Every id a form names is checked against the organization;
limits, accounts, the organization's name and the cluster stay with the
cluster's staff, and an admin cannot take their own role away.

A group from OIDC, SCIM or a directory no longer rewrites a membership
it names as a plain member at every sign-in, which took a manager's role
away (EnsureMembership).

Pages draw a form only for someone who may send it: Nav.May asks the
staff rule staffMay keeps, and a page whose forms a person may not send
gets no form token. A viewer sees the people, limits and mirror pages
without forms; an operator the mirror's.

Forms are compact: labels close to their fields, fields as wide as what
goes in them, smaller buttons, and the short forms on one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:38:06 +01:00

322 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.
{{/* The first tab of the people page: the tree, and everyone in it. */}}
{{define "people_body"}}
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
{{if .Message}}<p class="ok" role="status">{{.Message}}</p>{{end}}
{{if .Pending}}
<h2>{{t "users.pending.title"}}</h2>
<table class="rows"><tr><th>{{t "users.col.email"}}</th><th>{{t "users.col.name"}}</th><th>{{t "users.col.signed_up"}}</th><th>{{t "users.col.approve_into"}}</th><th></th></tr>
{{range .Pending}}<tr>
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
<td>
{{if $.Edit}}<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="project" aria-label="{{t "users.project_for" .Email}}">
<option value="">{{t "users.no_project"}}</option>
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
</select>
<button type="submit">{{t "users.approve"}}</button>
</form>{{end}}
</td>
<td>
{{if $.Edit}}<form method="post" action="/admin/users/{{.ID}}/delete" class="inline"
hx-confirm="{{t "users.reject_confirm" .Email}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.reject"}}</button>
</form>{{end}}
</td>
</tr>{{end}}</table>
{{end}}
<h2>{{t "users.tree.title"}}</h2>
<p class="note">{{if .HasQuotas}}{{th "users.tree.note_quotas"}}{{else}}{{t "users.tree.note"}}{{end}}</p>
{{if not .Tree}}
<p class="empty">{{t "users.empty"}}</p>
{{end}}
{{range $org := .Tree}}
<section class="org">
<div class="node-head">
<strong class="node-name"><a href="/admin/users?sel=org:{{$org.ID}}" class="sel-link{{if eq $.Sel (printf "org:%s" $org.ID)}} on{{end}}">{{$org.Name}}</a></strong>
<span class="node-meta">{{tn "users.n_teams" (len $org.Teams)}}</span>
{{template "scope-members" (dict "Kind" "org" "ID" $org.ID "Name" $org.Name "D" $)}}
{{if $.Edit}}<details class="edit"><summary>{{t "users.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="{{t "users.new_name"}}">
<button type="submit">{{t "common.save"}}</button>
</form>
</details>{{end}}
{{if not $org.Teams}}
{{if $.Edit}}<form method="post" action="/admin/tenancy/org/{{$org.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_org_confirm" $org.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>{{end}}
{{end}}
</div>
{{range $team := $org.Teams}}
<div class="team">
<div class="node-head">
<span class="node-name"><a href="/admin/users?sel=team:{{$team.ID}}" class="sel-link{{if eq $.Sel (printf "team:%s" $team.ID)}} on{{end}}">{{$team.Name}}</a></span>
<span class="node-meta">{{tn "users.n_projects" (len $team.Projects)}}</span>
{{template "scope-members" (dict "Kind" "team" "ID" $team.ID "Name" $team.Name "D" $)}}
{{if $.Edit}}<details class="edit"><summary>{{t "users.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="{{t "users.new_name"}}">
<button type="submit">{{t "common.save"}}</button>
</form>
</details>{{end}}
{{if gt (len $.Orgs) 1}}
{{if $.Edit}}<details class="edit"><summary>{{t "users.move"}}</summary>
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/move">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="to" aria-label="{{t "users.move_team_to" $team.Name}}">
{{range $.Orgs}}{{if ne .ID $org.ID}}<option value="{{.ID}}">{{.Name}}</option>{{end}}{{end}}
</select>
<button type="submit">{{t "users.move_button"}}</button>
</form>
</details>{{end}}
{{end}}
{{if not $team.Projects}}
{{if $.Edit}}<form method="post" action="/admin/tenancy/team/{{$team.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_team_confirm" $team.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>{{end}}
{{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>
<a href="/admin/users?sel=project:{{$p.ID}}&amp;open={{$p.ID}}" class="sel-link small{{if eq $.Sel (printf "project:%s" $p.ID)}} on{{end}}" title="{{t "users.limits_of" $p.Name}}">{{t "users.limits_and_use"}}</a>
<span class="node-meta">{{tn "users.n_members" $p.Users}}
· {{tn "users.n_keys" $p.ActiveKeys}}{{if gt $p.Keys $p.ActiveKeys}} {{t "users.n_revoked" (sub $p.Keys $p.ActiveKeys)}}{{end}}</span>
</summary>
<div class="project-body">
<div class="node-head">
{{if $.Edit}}<details class="edit"><summary>{{t "users.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="{{t "users.new_name"}}">
<button type="submit">{{t "common.save"}}</button>
</form>
</details>{{end}}
{{if gt (len $.Teams) 1}}
{{if $.Edit}}<details class="edit"><summary>{{t "users.move_project"}}</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="{{t "users.move_project_to" $p.Name}}">
{{range $.Teams}}{{if ne .ID $team.ID}}<option value="{{.ID}}">{{.OrgName}} / {{.Name}}</option>{{end}}{{end}}
</select>
<button type="submit">{{t "users.move_button"}}</button>
</form>
</details>{{end}}
{{end}}
{{if and (eq $p.Users 0) (eq $p.Keys 0)}}
{{if $.Edit}}<form method="post" action="/admin/tenancy/project/{{$p.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_project_confirm" $p.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>{{end}}
{{else}}<span class="note">{{t "users.not_removable"}}</span>{{end}}
</div>
{{with index $.Members (printf "project:%s" $p.ID)}}
<table class="rows compact"><tr><th>{{t "users.col.member"}}</th><th>{{t "users.col.state"}}</th><th>{{t "users.col.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">{{t "users.no_members"}}</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>{{t "users.col.key"}}</th><th>{{t "users.col.owner"}}</th><th>{{t "users.col.state"}}</th><th>{{t "users.col.last_used"}}</th><th></th></tr>
{{range .}}<tr>
<td>{{.Name}} <code>{{.Prefix}}</code></td>
<td>{{or .OwnerEmail (t "users.issued_by_operator")}}</td>
<td class="state state-{{.State}}">{{t (print "users.key_state." .State)}}</td>
<td>{{ago .LastUsedAt}}</td>
<td>{{if eq .State "active"}}
{{if $.Edit}}<form method="post" action="/admin/keys/{{.ID}}/revoke" class="inline"
hx-confirm="{{t "users.revoke_confirm" .Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="open" value="{{$p.ID}}">
<button type="submit" class="link danger">{{t "users.revoke"}}</button>
</form>{{end}}{{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. */}}
{{if $.Edit}}<details class="add-inline">
<summary>{{t "users.add_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="{{t "users.new_project_in" $team.Name}}" aria-label="{{t "users.new_project_in_label" $team.Name}}">
<button type="submit">{{t "common.add"}}</button>
</form>
</details>{{end}}
</div>
{{end}}
{{if $.Edit}}<details class="add-inline team-add">
<summary>{{t "users.add_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="{{t "users.new_team_in" $org.Name}}" aria-label="{{t "users.new_team_in_label" $org.Name}}">
<button type="submit">{{t "common.add"}}</button>
</form>
</details>{{end}}
</section>
{{end}}
{{if $.Edit}}<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="{{t "users.new_org"}}" aria-label="{{t "users.new_org_label"}}">
<button type="submit">{{t "users.add_org"}}</button>
</form>{{end}}
<h2 id="accounts">{{t "users.accounts"}}</h2>
{{if .Accounts}}
<table class="rows" id="account-list"><tr><th>{{t "users.col.person"}}</th><th>{{t "users.col.state"}}</th><th>{{t "users.col.member_of"}}</th><th>{{t "users.col.last_sign_in"}}</th><th>{{t "users.col.add_to"}}</th><th></th></tr>
{{range .Accounts}}{{template "account-row" (dict "U" . "D" $)}}{{end}}
</table>
<p class="note">{{t "users.alone_note"}}</p>
{{else}}<p class="empty">{{t "users.no_accounts"}}</p>{{end}}
<p class="note">{{th "users.shell_note"}}</p>
{{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="{{t "users.more_for" $u.Email}}" title="{{t "users.more"}}">⋯</summary>
<div class="menu">
{{if eq (printf "%s" $u.State) "active"}}
{{if $d.Edit}}<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">{{t "users.suspend"}}</button>
</form>{{end}}
{{else}}
{{if $d.Edit}}<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">{{t "users.reinstate"}}</button>
</form>{{end}}
{{end}}
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_confirm" $u.Email}}">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="open" value="{{.Open}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>{{end}}
</div>
</details>
{{else}}<span class="note">{{t "users.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><a href="/admin/users?sel=user:{{$u.ID}}" class="sel-link{{if eq $d.Sel (printf "user:%s" $u.ID)}} on{{end}}">{{$u.Email}}</a>{{if $u.IsAdmin}} <span class="tag">{{t "users.tag.admin"}}</span>{{end}}</td>
<td class="state state-{{$u.State}}">{{t (print "users.state." $u.State)}}</td>
<td class="where">
{{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}<span class="chip-m">{{.Path}}
{{if and (eq (printf "%s" .Scope) "org") $d.Edit}}
<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}}">
<select name="role" class="chip-sel" data-autosubmit aria-label="{{t "users.role_in" .Path}}" title="{{t "users.role_note"}}">
{{$r := .Role}}{{range $role := roles}}<option value="{{$role}}"{{if eq $role $r}} selected{{end}}>{{t (print "users.role." $role)}}</option>{{end}}
</select>
</form>
{{else if ne .Role "member"}} <b class="chip-role">{{t (print "users.role." .Role)}}</b>{{end}}
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
hx-confirm="{{t "users.take_out_confirm" $u.Email .Path}}">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}:{{.ScopeID}}">
<button type="submit" class="chip-x" aria-label="{{t "users.take_out_of" .Path}}" title="{{t "users.take_out_of" .Path}}">×</button>
</form>{{end}}</span>{{end}}{{end}}
{{else}}<span class="opt">{{t "users.on_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>
{{if $d.Edit}}<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="{{t "users.add_to_label" $u.Email}}" required>
<option value="">{{t "users.choose"}}</option>
{{range $d.Scopes}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<button type="submit" class="small">{{t "common.add"}}</button>
</form>{{end}}
</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">{{t "users.tag.admin"}}</span>{{end}}
{{if gt (len $u.Memberships) 1}}<span class="opt">{{t "users.also_in" (sub (len $u.Memberships) 1)}}</span>{{end}}</td>
<td class="state state-{{$u.State}}">{{t (print "users.state." $u.State)}}</td>
<td>{{ago $u.LastLoginAt}}</td>
<td class="actions">
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
hx-confirm="{{t "users.take_out_here_confirm" $u.Email}}">
<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">{{t "users.take_out"}}</button>
</form>{{end}}
</td>
</tr>{{end}}
{{/* Adding a person to a scope, from the scope. */}}
{{define "add-member"}}{{$d := .D}}
{{if $d.Edit}}<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="{{t "users.person_to_add"}}" required>
<option value="">{{t "users.choose_person"}}</option>
{{range $d.Accounts}}<option value="{{.ID}}">{{.Email}}</option>{{end}}
</select>
<button type="submit">{{t "users.add_member"}}</button>
</form>{{end}}{{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>{{tn "users.n_members" (len (index $d.Members $k))}}</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">{{t "users.nobody_in" .Name}}</p>{{end}}
{{template "add-member" (dict "Kind" .Kind "ID" .ID "D" $d "Open" "")}}
</div>
</details>{{end}}