vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add instance user administration
395554f453ca31d3bd325920a7011c91230e961c · Keenan Nemetz · 15d ago · parent 8e93a8f0
Add instance user administration Instance admins get an Admin -> Users surface: create an account from a one-time enrollment link, grant or revoke instance admin, retire an account, refresh its invite, inspect keys and sessions, remove a key, and revoke every session. Account creation uses CreateWithInvite, so the browser never asks an operator to paste somebody else's SSH key. Enrollment links are rendered once with no-store and warn when browser login is disabled. Retiring and admin changes keep the store's last-admin guard and audit trail.
server/web/templates/admin_users.html +184
| @@ -0,0 +1,184 @@ | |||
| 1 | {{define "admin_users"}} | ||
| 2 | {{template "header" .}} | ||
| 3 | <div class="page-head"> | ||
| 4 | <div class="page-head-main"> | ||
| 5 | <h1>Users</h1> | ||
| 6 | <p class="page-sub">Accounts, enrollment links, keys, and sessions. New accounts start with an enrollment link, never a pasted SSH key.</p> | ||
| 7 | </div> | ||
| 8 | <div class="page-head-actions"> | ||
| 9 | <a class="button secondary" href="/admin">{{icon "book-open"}}Repositories</a> | ||
| 10 | </div> | ||
| 11 | </div> | ||
| 12 | |||
| 13 | {{if .Error}}<div class="flash bad">{{.Error}}</div>{{end}} | ||
| 14 | |||
| 15 | <div class="card form-card"> | ||
| 16 | <h2>Create an account</h2> | ||
| 17 | <form method="post" action="/admin/users" class="form-block"> | ||
| 18 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 19 | <label>Account name | ||
| 20 | <input type="text" name="name" required maxlength="63" autocomplete="off" placeholder="lowercase-name"> | ||
| 21 | </label> | ||
| 22 | <label>Display name | ||
| 23 | <input type="text" name="display" maxlength="80" autocomplete="off" placeholder="Optional label"> | ||
| 24 | </label> | ||
| 25 | <div class="form-actions"><button type="submit">{{icon "user"}}Create invite</button></div> | ||
| 26 | </form> | ||
| 27 | </div> | ||
| 28 | |||
| 29 | <div class="panel"> | ||
| 30 | <table> | ||
| 31 | <thead><tr><th>Account</th><th>Status</th><th>Role</th><th>Keys</th><th>Sessions</th><th></th></tr></thead> | ||
| 32 | <tbody> | ||
| 33 | {{range .Users}} | ||
| 34 | <tr> | ||
| 35 | <td> | ||
| 36 | <a href="/admin/users/{{.Account.ID}}">{{.Account.Label}}</a> | ||
| 37 | {{if ne .Account.Name .Account.Label}}<span class="muted">@{{.Account.Name}}</span>{{end}} | ||
| 38 | </td> | ||
| 39 | <td><span class="state {{.Account.Status}}">{{.Account.Status}}</span></td> | ||
| 40 | <td>{{if .Account.Admin}}<span class="label">instance admin</span>{{else}}<span class="muted">account</span>{{end}}</td> | ||
| 41 | <td>{{len .Account.Keys}}</td> | ||
| 42 | <td>{{.Sessions}}</td> | ||
| 43 | <td><a href="/admin/users/{{.Account.ID}}">Manage</a></td> | ||
| 44 | </tr> | ||
| 45 | {{end}} | ||
| 46 | </tbody> | ||
| 47 | </table> | ||
| 48 | </div> | ||
| 49 | {{template "footer" .}} | ||
| 50 | {{end}} | ||
| 51 | |||
| 52 | {{define "admin_user"}} | ||
| 53 | {{template "header" .}} | ||
| 54 | <div class="page-head"> | ||
| 55 | <div class="page-head-main"> | ||
| 56 | <h1>{{.Account.Label}}</h1> | ||
| 57 | <p class="page-sub"> | ||
| 58 | <code>@{{.Account.Name}}</code> | ||
| 59 | · <span class="state {{.Account.Status}}">{{.Account.Status}}</span> | ||
| 60 | {{if .Account.Admin}}· instance admin{{end}} | ||
| 61 | · created {{since .Account.Created.Unix}} | ||
| 62 | </p> | ||
| 63 | </div> | ||
| 64 | <div class="page-head-actions"> | ||
| 65 | <a class="button secondary" href="/admin/users">{{icon "users"}}All users</a> | ||
| 66 | </div> | ||
| 67 | </div> | ||
| 68 | |||
| 69 | {{if .Error}}<div class="flash bad">{{.Error}}</div>{{end}} | ||
| 70 | |||
| 71 | <div class="card form-card"> | ||
| 72 | <h2>Access and enrollment</h2> | ||
| 73 | <div class="form-actions"> | ||
| 74 | <form class="inline" method="post" action="/admin/users/{{.Account.ID}}/admin"> | ||
| 75 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 76 | {{if .Account.Admin}} | ||
| 77 | <input type="hidden" name="admin" value="false"> | ||
| 78 | <button type="submit" class="secondary">{{icon "user"}}Revoke instance admin</button> | ||
| 79 | {{else}} | ||
| 80 | <input type="hidden" name="admin" value="true"> | ||
| 81 | <button type="submit" class="secondary">{{icon "user"}}Grant instance admin</button> | ||
| 82 | {{end}} | ||
| 83 | </form> | ||
| 84 | <form class="inline" method="post" action="/admin/users/{{.Account.ID}}/invite"> | ||
| 85 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 86 | <button type="submit" class="secondary">{{icon "external-link"}}New enrollment link</button> | ||
| 87 | </form> | ||
| 88 | {{if .Sessions}} | ||
| 89 | <form class="inline" method="post" action="/admin/users/{{.Account.ID}}/sessions/revoke"> | ||
| 90 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 91 | <button type="submit" class="warn">{{icon "log-out"}}Revoke all sessions</button> | ||
| 92 | </form> | ||
| 93 | {{end}} | ||
| 94 | </div> | ||
| 95 | </div> | ||
| 96 | |||
| 97 | <h2>SSH keys</h2> | ||
| 98 | {{if .Account.Keys}} | ||
| 99 | <div class="panel"> | ||
| 100 | <table> | ||
| 101 | <thead><tr><th>Fingerprint</th><th>Type</th><th>Comment</th><th></th></tr></thead> | ||
| 102 | <tbody> | ||
| 103 | {{range .Account.Keys}} | ||
| 104 | <tr> | ||
| 105 | <td><code>{{.Fingerprint}}</code></td> | ||
| 106 | <td>{{.Type}}</td> | ||
| 107 | <td class="muted">{{.Comment}}</td> | ||
| 108 | <td> | ||
| 109 | <form class="inline" method="post" action="/admin/users/{{$.Account.ID}}/keys/remove"> | ||
| 110 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> | ||
| 111 | <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> | ||
| 112 | <button type="submit" class="danger">Remove</button> | ||
| 113 | </form> | ||
| 114 | </td> | ||
| 115 | </tr> | ||
| 116 | {{end}} | ||
| 117 | </tbody> | ||
| 118 | </table> | ||
| 119 | </div> | ||
| 120 | {{else}} | ||
| 121 | <p class="muted">No SSH keys. This account can sign in with a passkey only.</p> | ||
| 122 | {{end}} | ||
| 123 | |||
| 124 | <h2>Sessions</h2> | ||
| 125 | {{if .Sessions}} | ||
| 126 | <div class="panel"> | ||
| 127 | <table> | ||
| 128 | <thead><tr><th>Created</th><th>Last seen</th><th>Expires</th><th>User agent</th></tr></thead> | ||
| 129 | <tbody> | ||
| 130 | {{range .Sessions}} | ||
| 131 | <tr> | ||
| 132 | <td class="nowrap">{{since .Created.Unix}}</td> | ||
| 133 | <td class="nowrap">{{since .LastSeen.Unix}}</td> | ||
| 134 | <td class="nowrap">{{.Expires.UTC.Format "2006-01-02 15:04"}}</td> | ||
| 135 | <td class="muted">{{.UserAgent}}</td> | ||
| 136 | </tr> | ||
| 137 | {{end}} | ||
| 138 | </tbody> | ||
| 139 | </table> | ||
| 140 | </div> | ||
| 141 | {{else}} | ||
| 142 | <p class="muted">No browser sessions.</p> | ||
| 143 | {{end}} | ||
| 144 | |||
| 145 | <h2>Danger zone</h2> | ||
| 146 | <div class="card danger-zone"> | ||
| 147 | <form method="post" action="/admin/users/{{.Account.ID}}/retire" class="form-block"> | ||
| 148 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 149 | <h3>Retire account</h3> | ||
| 150 | <p class="muted">Disables the account, ends its sessions, and closes any enrollment links. The name is never reissued.</p> | ||
| 151 | <label>Type <code>{{.Account.Name}}</code> to confirm | ||
| 152 | <input type="text" name="confirm" required autocomplete="off"> | ||
| 153 | </label> | ||
| 154 | <div class="form-actions"><button type="submit" class="danger">{{icon "circle-x"}}Retire account</button></div> | ||
| 155 | </form> | ||
| 156 | </div> | ||
| 157 | {{template "footer" .}} | ||
| 158 | {{end}} | ||
| 159 | |||
| 160 | {{define "admin_invite"}} | ||
| 161 | {{template "header" .}} | ||
| 162 | <div class="page-head"> | ||
| 163 | <div class="page-head-main"> | ||
| 164 | <h1>Enrollment link</h1> | ||
| 165 | <p class="page-sub">For <strong>{{.Account.Label}}</strong> <code>@{{.Account.Name}}</code> · expires {{.Expires.UTC.Format "2006-01-02 15:04 UTC"}}</p> | ||
| 166 | </div> | ||
| 167 | </div> | ||
| 168 | |||
| 169 | {{if not .BrowserLogin}} | ||
| 170 | <div class="flash bad">Browser login is disabled on this instance, so this link cannot be used until public_url is set to an HTTPS URL.</div> | ||
| 171 | {{end}} | ||
| 172 | |||
| 173 | <div class="card"> | ||
| 174 | <p>Send this link to the account holder. It is shown once, and any earlier unused link for this account has been superseded.</p> | ||
| 175 | <p><code>{{.Link}}</code></p> | ||
| 176 | <p><button type="button" class="secondary" data-copy="{{.Link}}">{{icon "copy"}}Copy link</button></p> | ||
| 177 | </div> | ||
| 178 | |||
| 179 | <div class="form-actions"> | ||
| 180 | <a class="button secondary" href="/admin/users/{{.Account.ID}}">{{icon "user"}}Back to account</a> | ||
| 181 | <a class="button" href="/admin/users">{{icon "users"}}All users</a> | ||
| 182 | </div> | ||
| 183 | {{template "footer" .}} | ||
| 184 | {{end}} | ||