Files
keeporg/internal/server/pagesadmin.go
T
ReactorcoremeltdownandClaude Opus 5 81094c0228 Add the administration pages to the web interface
Running the server meant running the API by hand: invites, accounts,
groups and grants existed only as endpoints and CLI commands, so the
browser could create a vault and nothing else.

/web/groups walks the group tree and opens onto one group's members,
roles and vault access, including the grants it inherits from above and
where they come from. /web/users lists the accounts you may see, and
lets a server administrator disable, enable, clear a lost second
factor, promote, demote or delete one. /web/invites shows the
outstanding links with their use counts, issues new ones and revokes
them.

Every administrative change is now a function of the state and the
caller in admin.go, which both the JSON handlers and the forms call. The
alternative was a second copy of the permission rules behind the pages,
which would have drifted on the first change. The same move gave the API
the two verbs it was missing: DELETE /api/v1/invites/{token} and
PATCH /api/v1/users/{id}.

Account changes are server-administrator only. A group administrator
decides who reaches which vault; disabling an account or clearing
somebody's second factor reaches past the group they share. Disable and
delete revoke that account's sessions, and the three changes that would
lock the caller out are refused on their own account.

Pages go through webPage and forms through webForm, so the session and
CSRF checks cannot be forgotten on a new form. No page carries any
JavaScript.

Server 0.6.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-30 12:32:08 +01:00

352 lines
13 KiB
Go

package server
import (
"html/template"
"net/http"
"cc.tiredsysadmin.keeporg/internal/store"
)
// The administration pages. Each control is its own small form carrying the
// session's CSRF token and one action, which is why there is no JavaScript
// here and no way to act on a page you were not served.
var groupsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
<h1>Groups</h1>
<p class="sub">Nested groups decide who reaches which vault.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Groups}}
<div class="scroll"><table>
<thead><tr><th>Group</th><th>People</th><th>Vaults</th><th></th></tr></thead>
<tbody>
{{range .Groups}}<tr>
<td class="name" style="padding-left:{{.Pad}}px"><a href="/web/groups/{{.ID}}">{{.Name}}</a></td>
<td>{{.Members}}</td>
<td>{{.Owns}}</td>
<td>{{if .Admin}}<span class="tag">you administer</span>{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{else}}
<p class="empty">There are no groups you can see.</p>
{{end}}
{{if or .Parents .ServerAdmin}}
<fieldset><legend>New group</legend>
<form class="inline" method="post" action="/web/groups">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div><label for="name">Name</label>
<input id="name" name="name" required placeholder="Engineering"></div>
<div><label for="parent">Inside</label>
<select id="parent" name="parent">
{{if .ServerAdmin}}<option value="">(top level)</option>{{end}}
{{range .Parents}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select></div>
<button type="submit">Create group</button>
</form>
<p class="note">Access flows downwards: a vault granted to a group is reachable by
every group inside it, and whoever administers a group administers everything
inside it too.</p>
</fieldset>
{{end}}
{{end}}`))
var groupTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
{{with .G}}
<h1>{{.Name}}</h1>
<p class="sub">{{if .Parent}}Inside {{.Parent}}. {{end}}<a href="/web/groups">All groups</a></p>
{{end}}
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{with .G}}
<h2>People</h2>
{{if .Members}}
<div class="scroll"><table>
<thead><tr><th>Who</th><th>Role</th><th></th></tr></thead>
<tbody>
{{range .Members}}<tr{{if .Disabled}} class="gone"{{end}}>
<td class="name">{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td>
<td>{{.Role}}</td>
<td>{{if $.G.Admin}}
<form method="post" action="/web/groups/{{$.G.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="member">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="role" value="{{if eq .Role "admin"}}member{{else}}admin{{end}}">
<button type="submit">Make {{if eq .Role "admin"}}member{{else}}admin{{end}}</button>
</form>
<form method="post" action="/web/groups/{{$.G.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="member-remove">
<input type="hidden" name="user" value="{{.ID}}">
<button class="danger" type="submit">Remove</button>
</form>
{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{else}}<p class="empty">Nobody is in this group yet.</p>{{end}}
{{if .Admin}}
<fieldset><legend>Add somebody</legend>
<form class="inline" method="post" action="/web/groups/{{.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="member">
<div><label for="user">Username</label>
<input id="user" name="user" list="people" required autocomplete="off">
<datalist id="people">{{range .People}}<option value="{{.Name}}">{{end}}</datalist></div>
<div><label for="role">Role</label>
<select id="role" name="role">
<option value="member">Member</option>
<option value="admin">Administrator of this group</option>
</select></div>
<button type="submit">Add</button>
</form>
<p class="note">Only accounts that already exist can be added. To bring in somebody
new, send them an <a href="/web/invites">invite link</a> for this group.</p>
</fieldset>
{{end}}
<h2>Vaults this group reaches</h2>
{{if .Access}}
<div class="scroll"><table>
<thead><tr><th>Vault</th><th>Access</th><th>Where from</th><th></th></tr></thead>
<tbody>
{{range .Access}}<tr>
<td class="name">{{.Name}}.kdbx</td>
<td>{{.Perm}}</td>
<td>{{if .Owned}}owns it{{else if .Via}}granted to {{.Via}}{{else}}granted here{{end}}</td>
<td>{{if and $.G.Admin (not .Owned) (not .Via)}}
<form method="post" action="/web/groups/{{$.G.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="grant-remove">
<input type="hidden" name="database" value="{{.DatabaseID}}">
<button class="danger" type="submit">Revoke</button>
</form>
{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{else}}<p class="empty">This group reaches no vaults.</p>{{end}}
{{if and .Admin .Vaults}}
<fieldset><legend>Grant access</legend>
<form class="inline" method="post" action="/web/groups/{{.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="grant">
<div><label for="database">Vault</label>
<select id="database" name="database" required>
{{range .Vaults}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select></div>
<div><label for="perm">May</label>
<select id="perm" name="perm">
<option value="read">Read it</option>
<option value="write">Read and save it</option>
<option value="admin">Administer it</option>
</select></div>
<button type="submit">Grant</button>
</form>
</fieldset>
{{end}}
{{if .Children}}
<h2>Groups inside this one</h2>
<div class="scroll"><table><tbody>
{{range .Children}}<tr>
<td class="name"><a href="/web/groups/{{.ID}}">{{.Name}}</a></td>
<td>{{.Members}} people</td><td>{{.Owns}} vaults</td>
</tr>{{end}}
</tbody></table></div>
{{end}}
{{if .Admin}}
<details>
<summary>Delete this group</summary>
<p class="sub">A group can only go once it has no members, no child groups and owns
no vaults. Deleting it also drops the grants that hang on it.</p>
<form method="post" action="/web/groups/{{.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="delete">
<button type="submit">Delete {{.Name}}</button>
</form>
</details>
{{end}}
{{end}}
{{end}}`))
var usersTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
<h1>People</h1>
<p class="sub">{{if .ServerAdmin}}Every account on this server.{{else}}The people in the
groups you administer.{{end}}</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
<div class="scroll"><table>
<thead><tr><th>Who</th><th>Signs in with</th><th>Groups</th>{{if .ServerAdmin}}<th></th>{{end}}</tr></thead>
<tbody>
{{range .Users}}<tr{{if .Disabled}} class="gone"{{end}}>
<td class="name">{{.Username}}
{{if .ServerAdmin}}<span class="tag">server admin</span>{{end}}
{{if .Disabled}}<span class="tag">disabled</span>{{end}}
{{if .Email}}<br><small>{{.Email}}</small>{{end}}</td>
<td>{{.SignIn}}{{if .TOTP}}<br><small>one-time code</small>{{end}}</td>
<td>{{if .Groups}}{{.Groups}}{{else}}<small>none</small>{{end}}</td>
{{if $.ServerAdmin}}<td>
{{if .Disabled}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="enable">
<button type="submit">Enable</button>
</form>
{{else if not .Self}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="disable">
<button type="submit">Disable</button>
</form>
{{end}}
{{if .TOTP}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="clear-totp">
<button type="submit">Clear 2FA</button>
</form>
{{end}}
{{if not .Self}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="{{if .ServerAdmin}}demote{{else}}promote{{end}}">
<button type="submit">{{if .ServerAdmin}}Drop server admin{{else}}Make server admin{{end}}</button>
</form>
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="delete">
<button class="danger" type="submit">Delete</button>
</form>
{{end}}
</td>{{end}}
</tr>{{end}}
</tbody></table></div>
{{if .ServerAdmin}}
<p class="note">Disabling an account signs it out everywhere and keeps its history;
deleting it removes the account and its group memberships. Clearing the second
factor is what you do when somebody loses their phone — they can sign in with
their password alone until they enrol a new one.</p>
{{else}}
<p class="note">Only a server administrator can change an account. You decide who
is in your groups, from each <a href="/web/groups">group's page</a>.</p>
{{end}}
{{end}}`))
var invitesTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
<h1>Invites</h1>
<p class="sub">An invite link lets somebody create their own account and lands them
in one group.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Invites}}
<div class="scroll"><table>
<thead><tr><th>Link</th><th>Joins</th><th>Used</th><th>Expires</th><th></th></tr></thead>
<tbody>
{{range .Invites}}<tr{{if .Expired}} class="gone"{{end}}>
<td><input readonly value="{{.URL}}" aria-label="invite link"></td>
<td>{{.Group}}<br><small>as {{.Role}}</small></td>
<td>{{.Uses}} of {{.Max}}</td>
<td>{{.Expires}}{{if .Expired}}<br><small>spent or expired</small>{{end}}
{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
<td>
<form method="post" action="/web/invites">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="token" value="{{.Token}}">
<button class="danger" type="submit">Revoke</button>
</form>
</td>
</tr>{{end}}
</tbody></table></div>
{{else}}
<p class="empty">No invites outstanding.</p>
{{end}}
{{if or .Groups .ServerAdmin}}
<fieldset><legend>New invite</legend>
<form class="inline" method="post" action="/web/invites">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="create">
<div><label for="group">Joins</label>
<select id="group" name="group">
{{range .Groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
{{if .ServerAdmin}}<option value="">(no group)</option>{{end}}
</select></div>
<div><label for="role">As</label>
<select id="role" name="role">
<option value="member">Member</option>
<option value="admin">Administrator of that group</option>
</select></div>
<div><label for="ttl">Valid for</label>
<select id="ttl" name="ttl">
<option value="1">1 hour</option>
<option value="24">1 day</option>
<option value="168" selected>7 days</option>
<option value="720">30 days</option>
</select></div>
<div><label for="uses">Uses</label>
<select id="uses" name="uses">
<option value="1">1</option>
<option value="5">5</option>
<option value="25">25</option>
<option value="-1">unlimited</option>
</select></div>
<button type="submit">Create invite</button>
</form>
<p class="note">Anyone holding the link can create an account, so send it the way you
would send a password and keep it short-lived. Revoking a link stops it at once.</p>
</fieldset>
{{end}}
{{end}}`))
func (s *Server) renderGroups(w http.ResponseWriter, sess *webSession, errMsg, notice string) {
d := s.shell(sess, "Groups", "Groups")
s.st.Read(func(st *store.State) {
d["Groups"] = groupTree(st, sess.user)
d["Parents"] = administeredGroups(st, sess.user)
})
d["Error"], d["Notice"] = errMsg, notice
render(w, groupsTmpl, d)
}
func (s *Server) renderGroup(w http.ResponseWriter, sess *webSession, id, errMsg, notice string) {
d := s.shell(sess, "Group", "Groups")
var detail *webGroupDetail
s.st.Read(func(st *store.State) { detail = groupDetail(st, sess.user, id) })
if detail == nil {
s.renderGroups(w, sess, "There is no such group, or it is not yours to see.", "")
return
}
d["G"], d["Error"], d["Notice"] = detail, errMsg, notice
render(w, groupTmpl, d)
}
func (s *Server) renderUsers(w http.ResponseWriter, sess *webSession, errMsg, notice string) {
d := s.shell(sess, "People", "People")
s.st.Read(func(st *store.State) { d["Users"] = userRows(st, sess.user) })
d["Error"], d["Notice"] = errMsg, notice
render(w, usersTmpl, d)
}
func (s *Server) renderInvites(w http.ResponseWriter, sess *webSession, errMsg, notice string) {
d := s.shell(sess, "Invites", "Invites")
s.st.Read(func(st *store.State) {
d["Invites"] = s.inviteRows(st, sess.user)
d["Groups"] = administeredGroups(st, sess.user)
})
d["Error"], d["Notice"] = errMsg, notice
render(w, invitesTmpl, d)
}