Files
ReactorcoremeltdownandClaude Opus 5.5 f8f634fbf7 Add an Account page for sign-in methods and signed-in devices
/web/account, offered to everyone, lists every browser and client signed
in as you, with when each was last used, and signs any of them out, or all
but the current one. It sets up, replaces and removes the second factor,
and holds the password form, moved from the vault page. Administrators can
sign a user out everywhere from the People page.

The API gains GET and DELETE /api/v1/auth/sessions and DELETE
/api/v1/auth/totp. Sessions are named by a digest of their token, never the
token, and record when they were last used. Replacing or removing an active
second factor now needs the password and a current code.

Server version 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:37:47 +01:00

358 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="sign-out">
<button type="submit">Sign out everywhere</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="{{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)
}