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>
322 lines
17 KiB
HTML
322 lines
17 KiB
HTML
{{/* 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}}&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}} {{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}}
|