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