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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Fold admin work into repository settings and a Users page

9579c1f976254fdeb2ec73d4016db5b25c7921c1 · Keenan Nemetz · 14d ago · parent 381d6aac

Fold admin work into repository settings and a Users page

Remove the combined /admin tabs. Per-repository access rules now live on the repository's Settings page, visible to instance admins while repository details and the danger zone stay with repository admins. User management moves to /users, linked from the top navigation only for instance admins, and repository creation moves to /repos/new. Align the invite, danger-zone, and settings forms with the shared card-and-actions layout, and make settings section headings consistent.

server/web/admin.go +4−184

@@ -1,31 +1,20 @@
1 1 package web
2 2
3 // Instance administration starts with the per-repository access rules. The
4 // routes are instance-admin only: a repository admin manages description,
5 // branch, and visibility, but not who gets which role or what CI may be trusted.
3 // Instance administration is split across the surfaces it belongs to: accounts
4 // live on their own Users page in the top navigation, and a repository's access
5 // rules are part of that repository's Settings page. Both are instance-admin
6 // operations; repository admins own the rest of Settings.
6 7
7 8 import (
8 9 "net/http"
9 10 "slices"
10 11 "sort"
11 "strconv"
12 "strings"
13 12
14 13 "github.com/rodent-software/vole/forge/access"
15 "github.com/rodent-software/vole/forge/repo"
16 14 )
17 15
18 16 var aclRoles = []string{"none", "read", "contributor", "maintain", "admin"}
19 17
20 type adminRepoView struct {
21 Name string
22 Description string
23 Public bool
24 Default string
25 Principals int
26 Runners int
27 }
28
29 18 type aclRow struct {
30 19 Name string
31 20 Status string
@@ -33,21 +22,6 @@ type aclRow struct {
33 22 Runner bool
34 23 }
35 24
36 type adminAccessView struct {
37 Page
38 Name string
39 Description string
40 Public bool
41 Default string
42 Approvals int
43 Checks bool
44 Protected string
45 Roles []string
46 Rows []aclRow
47 Error string
48 Saved bool
49 }
50
51 25 // requireInstanceAdmin redirects an anonymous visitor to login and hides the
52 26 // admin surface from an authenticated non-admin.
53 27 func (w *Web) requireInstanceAdmin(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
@@ -62,54 +36,6 @@ func (w *Web) requireInstanceAdmin(rw http.ResponseWriter, r *http.Request) (*pr
62 36 return pr, true
63 37 }
64 38
65 func (w *Web) AdminPage(rw http.ResponseWriter, r *http.Request) {
66 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
67 return
68 }
69 repos, err := w.Repos.List()
70 if err != nil {
71 w.serverError(rw, r, "cannot list repositories: "+err.Error())
72 return
73 }
74 views := make([]adminRepoView, 0, len(repos))
75 for _, m := range repos {
76 acl, err := m.ACL()
77 if err != nil {
78 w.serverError(rw, r, "cannot read access rules for "+m.Name+": "+err.Error())
79 return
80 }
81 views = append(views, adminRepoView{
82 Name: m.Name,
83 Description: m.Description,
84 Public: acl.Can("", access.Read),
85 Default: acl.Default,
86 Principals: len(acl.Entries),
87 Runners: len(acl.Runners),
88 })
89 }
90 w.render(rw, "admin", struct {
91 Page
92 Repos []adminRepoView
93 }{w.page(r, "Admin"), views}, 0)
94 }
95
96 func (w *Web) adminAccessView(r *http.Request, m *repo.Meta, acl *access.ACL, msg string) adminAccessView {
97 return adminAccessView{
98 Page: w.page(r, m.Name+" · access"),
99 Name: m.Name,
100 Description: m.Description,
101 Public: acl.Can("", access.Read),
102 Default: acl.Default,
103 Approvals: acl.RequiredApprovals(),
104 Checks: acl.RequiresChecks(),
105 Protected: strings.Join(acl.Protected, "\n"),
106 Roles: aclRoles,
107 Rows: w.aclRows(acl),
108 Error: msg,
109 Saved: r.URL.Query().Get("saved") == "1",
110 }
111 }
112
113 39 func (w *Web) aclRows(a *access.ACL) []aclRow {
114 40 isRunner := func(id string) bool {
115 41 for _, runner := range a.Runners {
@@ -157,109 +83,3 @@ func (w *Web) aclRows(a *access.ACL) []aclRow {
157 83 }
158 84 return rows
159 85 }
160
161 func (w *Web) AdminAccessPage(rw http.ResponseWriter, r *http.Request) {
162 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
163 return
164 }
165 m, err := w.Repos.Open(r.PathValue("name"))
166 if err != nil {
167 w.notFound(rw, r)
168 return
169 }
170 acl, err := m.ACL()
171 if err != nil {
172 w.serverError(rw, r, "cannot read access rules: "+err.Error())
173 return
174 }
175 w.render(rw, "admin_access", w.adminAccessView(r, m, acl, ""), 0)
176 }
177
178 func (w *Web) SaveAdminAccess(rw http.ResponseWriter, r *http.Request) {
179 pr, ok := w.requireInstanceAdmin(rw, r)
180 if !ok {
181 return
182 }
183 m, err := w.Repos.Open(r.PathValue("name"))
184 if err != nil {
185 w.notFound(rw, r)
186 return
187 }
188 acl, err := m.ACL()
189 if err != nil {
190 w.serverError(rw, r, "cannot read access rules: "+err.Error())
191 return
192 }
193 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
194 if !w.checkCSRF(r) {
195 http.Error(rw, "bad request", http.StatusForbidden)
196 return
197 }
198 if err := r.ParseForm(); err != nil {
199 http.Error(rw, "cannot read the form", http.StatusBadRequest)
200 return
201 }
202
203 updated := *acl
204 updated.Entries = map[string]string{}
205 defaultRole, err := access.ParseRole(r.PostFormValue("default"))
206 if err != nil {
207 http.Error(rw, err.Error(), http.StatusBadRequest)
208 return
209 }
210 updated.Default = defaultRole.String()
211 approvals, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("approvals")))
212 if err != nil || approvals < 0 {
213 http.Error(rw, "approvals must be a non-negative number", http.StatusBadRequest)
214 return
215 }
216 updated.ApprovalsRequired = &approvals
217 checks := r.PostFormValue("checks") != ""
218 updated.ChecksRequired = &checks
219 updated.Protected = splitLines(r.PostFormValue("protected"))
220
221 var runners []string
222 for _, row := range w.aclRows(acl) {
223 // A field the form did not submit keeps whatever the ACL already had,
224 // so a race with a newly added entry cannot silently delete it.
225 if raw, present := r.PostForm["role:"+row.Name]; present {
226 role, err := access.ParseRole(raw[0])
227 if err != nil {
228 http.Error(rw, "account "+row.Name+": "+err.Error(), http.StatusBadRequest)
229 return
230 }
231 if role != access.None {
232 updated.Entries[row.Name] = role.String()
233 }
234 } else if existing, ok := acl.Entries[row.Name]; ok {
235 updated.Entries[row.Name] = existing
236 }
237 if r.PostFormValue("runner:"+row.Name) != "" {
238 runners = append(runners, row.Name)
239 }
240 }
241 updated.Runners = nil
242 updated.SetRunners(runners)
243 if err := m.SetACL(&updated, pr.account.Name); err != nil {
244 w.serverError(rw, r, "cannot save access rules: "+err.Error())
245 return
246 }
247 if w.Index != nil {
248 if err := w.Index.Upsert(m); err != nil {
249 w.serverError(rw, r, "cannot update the repository index: "+err.Error())
250 return
251 }
252 }
253 w.audit("web.admin.acl", pr.account.Name, m.Name)
254 http.Redirect(rw, r, "/admin/repos/"+m.Name+"/access?saved=1", http.StatusSeeOther)
255 }
256
257 func splitLines(raw string) []string {
258 var out []string
259 for _, line := range strings.Split(raw, "\n") {
260 if line = strings.TrimSpace(line); line != "" {
261 out = append(out, line)
262 }
263 }
264 return out
265 }