/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>
358 lines
13 KiB
Go
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)
|
|
}
|