Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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}}