feat(admin): split the admin page into sections
8 files changed, +328 −55
- README.md modified +2 −2
- admin.go modified +115 −21
- flow_test.go modified +41 −0
- mail_test.go modified +1 −0
- static/style.css modified +63 −8
- templates/admin.html modified +94 −18
- templates/register.html modified +2 −2
- web.go modified +10 −4
README.md modified
| @@ -23,12 +23,12 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 23 | 23 | - History search on the log page through git's pickaxe, either commits that change how often a string occurs (`-S`) or commits whose diff has a line matching a regex (`-G`), which answers when a line arrived or vanished without cloning |
| 24 | 24 | - Atom feeds of commits per branch, of issues and of pull requests per repository, and of every public commit by a user at `~you/feed`, which stand in for notifications when mail is not configured. Each page names its feeds in a `<link rel="alternate">`, so a reader finds them from the ordinary page URL |
| 25 | 25 | - Rename, transfer to another user and archive from the repository settings. The old path keeps working as a permanent redirect for the web, HTTPS clones and SSH, provided the visitor can read the repository at its new home, and an archived repository refuses pushes, pull requests and comments but stays readable |
| 26 | -- An optional email address on each account, verified by a link, used for password resets and for notifications about pull requests, reviews and issues on your repositories. Both can be turned off and nothing works without the address, so the whole thing is opt in | |
| 26 | +- An optional email address on each account, used for password resets and for notifications about pull requests, reviews and issues on your repositories when mail is configured. Whether the sign up form asks for one at all is an admin setting, off by default, with optional and required as the other two choices, and nobody has to confirm an address before using their account | |
| 27 | 27 | - TOTP two factor authentication with a QR code for the authenticator app and eight single use recovery codes, which is what a password alone cannot give you on something that accepts pushes |
| 28 | 28 | - SSH keys and access tokens live in settings. HTTPS auth is plain basic auth with a token or your password |
| 29 | 29 | - Relative links and images in a README resolve against the repository, so a `docs/diagram.png` reference renders rather than breaking |
| 30 | 30 | - Registration is open, invite only or closed, and an administrator switches between the three on the admin page or with `gitpocket registration`. Signing up needs only a name and password, and each address can create a handful of accounts every few minutes |
| 31 | -- `/admin` exists for accounts you mark with `gitpocket admin <name>` and 404s for everyone else. From there you flip registration, mint invite links, set the quota, order the front page and ban people | |
| 31 | +- `/admin` exists for accounts you mark with `gitpocket admin <name>` and 404s for everyone else. Its overview counts accounts, repositories and disk use, and separate sections handle people (ban, unban and grant admin), sign up (registration mode, the email question and invite links), the front page order and storage with a usage bar per owner | |
| 32 | 32 | |
| 33 | 33 | ## Design |
| 34 | 34 | |
admin.go modified
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "html/template" | |
| 5 | 4 | "net/http" |
| 5 | + "net/url" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "slices" | |
| 6 | 9 | "strconv" |
| 7 | 10 | "strings" |
| 8 | 11 | "time" |
| @@ -121,6 +124,27 @@ func (s *Store) Accounts() []account { | ||
| 121 | 124 | return out |
| 122 | 125 | } |
| 123 | 126 | |
| 127 | +var adminSections = []struct{ Key, Title string }{ | |
| 128 | + {"overview", "Overview"}, {"people", "People"}, {"signup", "Sign up"}, {"projects", "Front page"}, {"storage", "Storage"}, | |
| 129 | +} | |
| 130 | + | |
| 131 | +var signupEmailModes = []string{"off", "optional", "required"} | |
| 132 | + | |
| 133 | +func (s *Store) SignupEmail() string { | |
| 134 | + return s.Setting("signupemail", "off") | |
| 135 | +} | |
| 136 | + | |
| 137 | +type siteStats struct { | |
| 138 | + Users, Orgs, Admins, Banned, Repos, Private, Unlisted int | |
| 139 | +} | |
| 140 | + | |
| 141 | +func (s *Store) Stats() siteStats { | |
| 142 | + var st siteStats | |
| 143 | + s.db.QueryRow(`select coalesce(sum(org = 0 and banned = 0), 0), coalesce(sum(org = 1), 0), coalesce(sum(admin = 1), 0), coalesce(sum(banned = 1), 0) from users`).Scan(&st.Users, &st.Orgs, &st.Admins, &st.Banned) | |
| 144 | + s.db.QueryRow(`select count(*), coalesce(sum(private = 1), 0), coalesce(sum(private = 2), 0) from repos`).Scan(&st.Repos, &st.Private, &st.Unlisted) | |
| 145 | + return st | |
| 146 | +} | |
| 147 | + | |
| 124 | 148 | func (s *Server) admin(w http.ResponseWriter, r *http.Request) { |
| 125 | 149 | u := s.require(w, r) |
| 126 | 150 | if u == nil { |
| @@ -134,38 +158,104 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) { | ||
| 134 | 158 | s.adminAction(w, r, u) |
| 135 | 159 | return |
| 136 | 160 | } |
| 137 | - featured, _ := s.store.FeaturedRepos() | |
| 138 | - ranks := map[int64]int{} | |
| 139 | - for i, f := range featured { | |
| 140 | - ranks[f.ID] = i + 1 | |
| 161 | + section := r.URL.Query().Get("s") | |
| 162 | + known := false | |
| 163 | + for _, sec := range adminSections { | |
| 164 | + known = known || sec.Key == section | |
| 165 | + } | |
| 166 | + if !known { | |
| 167 | + section = "overview" | |
| 141 | 168 | } |
| 142 | - public, _ := s.store.PublicRepos("", "") | |
| 143 | - s.render(w, r, "admin", page{Title: "Admin", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{ | |
| 144 | - "Registration": s.store.Registration(), "Modes": registrationModes, "Invites": s.store.Invites(), | |
| 145 | - "Accounts": s.store.Accounts(), "Ranks": ranks, "Public": public, "Quota": compactSize(readQuota(s.root)), | |
| 146 | - }}) | |
| 169 | + data := map[string]any{"Section": section, "Sections": adminSections, "Saved": r.URL.Query().Get("saved") != ""} | |
| 170 | + switch section { | |
| 171 | + case "overview": | |
| 172 | + accounts := s.store.Accounts() | |
| 173 | + data["Stats"] = s.store.Stats() | |
| 174 | + data["Recent"] = accounts[:min(5, len(accounts))] | |
| 175 | + data["Registration"] = s.store.Registration() | |
| 176 | + data["SignupEmail"] = s.store.SignupEmail() | |
| 177 | + data["Mail"] = s.mailer != nil | |
| 178 | + data["Used"] = usage(filepath.Join(s.root, "repos")) | |
| 179 | + data["Quota"] = readQuota(s.root) | |
| 180 | + case "people": | |
| 181 | + q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) | |
| 182 | + var shown []account | |
| 183 | + for _, a := range s.store.Accounts() { | |
| 184 | + if q == "" || strings.Contains(a.Name, q) { | |
| 185 | + shown = append(shown, a) | |
| 186 | + } | |
| 187 | + } | |
| 188 | + data["Accounts"], data["Query"] = shown, q | |
| 189 | + case "signup": | |
| 190 | + data["Registration"] = s.store.Registration() | |
| 191 | + data["SignupEmail"] = s.store.SignupEmail() | |
| 192 | + data["Invites"] = s.store.Invites() | |
| 193 | + data["Mail"] = s.mailer != nil | |
| 194 | + case "projects": | |
| 195 | + featured, _ := s.store.FeaturedRepos() | |
| 196 | + ranks := map[int64]int{} | |
| 197 | + for i, f := range featured { | |
| 198 | + ranks[f.ID] = i + 1 | |
| 199 | + } | |
| 200 | + public, _ := s.store.PublicRepos("", "") | |
| 201 | + data["Ranks"], data["Public"] = ranks, public | |
| 202 | + case "storage": | |
| 203 | + quota := readQuota(s.root) | |
| 204 | + type owner struct { | |
| 205 | + Name string | |
| 206 | + Used int64 | |
| 207 | + Pct int | |
| 208 | + } | |
| 209 | + var owners []owner | |
| 210 | + dirs, _ := os.ReadDir(filepath.Join(s.root, "repos")) | |
| 211 | + for _, d := range dirs { | |
| 212 | + if !d.IsDir() { | |
| 213 | + continue | |
| 214 | + } | |
| 215 | + used := usage(filepath.Join(s.root, "repos", d.Name())) | |
| 216 | + pct := 0 | |
| 217 | + if quota > 0 { | |
| 218 | + pct = int(min(100, used*100/quota)) | |
| 219 | + } | |
| 220 | + if used > 0 { | |
| 221 | + pct = max(pct, 1) | |
| 222 | + } | |
| 223 | + owners = append(owners, owner{d.Name(), used, pct}) | |
| 224 | + } | |
| 225 | + slices.SortFunc(owners, func(a, b owner) int { return int(b.Used - a.Used) }) | |
| 226 | + if quota == 0 && len(owners) > 0 && owners[0].Used > 0 { | |
| 227 | + for i := range owners { | |
| 228 | + owners[i].Pct = max(1, int(owners[i].Used*100/owners[0].Used)) | |
| 229 | + } | |
| 230 | + } | |
| 231 | + data["Owners"], data["Quota"], data["QuotaText"] = owners, quota, compactSize(quota) | |
| 232 | + } | |
| 233 | + s.render(w, r, "admin", page{Title: "Admin", User: u, Error: r.URL.Query().Get("error"), Data: data}) | |
| 147 | 234 | } |
| 148 | 235 | |
| 149 | 236 | func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { |
| 150 | - back := "/admin" | |
| 237 | + section := r.FormValue("s") | |
| 238 | + back := "/admin?s=" + url.QueryEscape(section) | |
| 151 | 239 | fail := func(msg string) { |
| 152 | - http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(msg), http.StatusSeeOther) | |
| 240 | + http.Redirect(w, r, back+"&error="+url.QueryEscape(msg), http.StatusSeeOther) | |
| 153 | 241 | } |
| 154 | 242 | name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~") |
| 155 | 243 | switch r.FormValue("action") { |
| 156 | - case "registration": | |
| 157 | - mode := r.FormValue("mode") | |
| 158 | - if !validRegistration(mode) { | |
| 159 | - fail("Unknown registration mode") | |
| 244 | + case "signup": | |
| 245 | + mode, email := r.FormValue("mode"), r.FormValue("email") | |
| 246 | + if !validRegistration(mode) || !slices.Contains(signupEmailModes, email) { | |
| 247 | + fail("Unknown sign up setting") | |
| 160 | 248 | return |
| 161 | 249 | } |
| 162 | 250 | s.store.SetSetting("registration", mode) |
| 251 | + s.store.SetSetting("signupemail", email) | |
| 163 | 252 | case "invite": |
| 164 | 253 | if _, err := s.store.CreateInvite(); err != nil { |
| 165 | 254 | fail(err.Error()) |
| 166 | 255 | return |
| 167 | 256 | } |
| 168 | - back += "#invites" | |
| 257 | + case "revoke": | |
| 258 | + s.store.RevokeInvite(r.FormValue("code")) | |
| 169 | 259 | case "quota": |
| 170 | 260 | n, err := parseSize(strings.TrimSpace(r.FormValue("quota"))) |
| 171 | 261 | if err != nil { |
| @@ -185,7 +275,6 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { | ||
| 185 | 275 | fail("No such account") |
| 186 | 276 | return |
| 187 | 277 | } |
| 188 | - back += "#accounts" | |
| 189 | 278 | case "admin", "unadmin": |
| 190 | 279 | if name == u.Name { |
| 191 | 280 | fail("You cannot change your own admin rights") |
| @@ -195,7 +284,6 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { | ||
| 195 | 284 | fail("No such user") |
| 196 | 285 | return |
| 197 | 286 | } |
| 198 | - back += "#accounts" | |
| 199 | 287 | case "feature": |
| 200 | 288 | owner, repo, ok := strings.Cut(name, "/") |
| 201 | 289 | rank, _ := strconv.Atoi(r.FormValue("rank")) |
| @@ -203,12 +291,18 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { | ||
| 203 | 291 | fail("No such repository") |
| 204 | 292 | return |
| 205 | 293 | } |
| 206 | - back += "#featured" | |
| 207 | 294 | default: |
| 208 | 295 | fail("Unknown action") |
| 209 | 296 | return |
| 210 | 297 | } |
| 211 | - http.Redirect(w, r, back, http.StatusSeeOther) | |
| 298 | + if q := r.FormValue("q"); q != "" { | |
| 299 | + back += "&q=" + url.QueryEscape(q) | |
| 300 | + } | |
| 301 | + http.Redirect(w, r, back+"&saved=1", http.StatusSeeOther) | |
| 302 | +} | |
| 303 | + | |
| 304 | +func (s *Store) RevokeInvite(code string) { | |
| 305 | + s.db.Exec(`delete from invites where code = ? and used = 0`, code) | |
| 212 | 306 | } |
| 213 | 307 | |
| 214 | 308 | func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) { |
flow_test.go modified
| @@ -225,3 +225,44 @@ func TestWorkplaceLink(t *testing.T) { | ||
| 225 | 225 | t.Error("unknown workplace should be plain text") |
| 226 | 226 | } |
| 227 | 227 | } |
| 228 | + | |
| 229 | +func TestSignupEmailSetting(t *testing.T) { | |
| 230 | + s, h := testServer(t) | |
| 231 | + post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}, "email": {"a@example.org"}}, "") | |
| 232 | + u, _ := s.store.UserByName("alice") | |
| 233 | + if s.store.Account(u.ID).Email != "" { | |
| 234 | + t.Error("email stored while the setting is off") | |
| 235 | + } | |
| 236 | + if strings.Contains(get(h, "/register", "").Body.String(), `name="email"`) { | |
| 237 | + t.Error("email field shown while off") | |
| 238 | + } | |
| 239 | + s.store.SetSetting("signupemail", "required") | |
| 240 | + if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}}, ""); w.Code != 400 { | |
| 241 | + t.Errorf("required email not enforced: %d", w.Code) | |
| 242 | + } | |
| 243 | + if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "email": {"b@example.org"}}, ""); w.Code != http.StatusSeeOther { | |
| 244 | + t.Errorf("registration with required email failed: %d", w.Code) | |
| 245 | + } | |
| 246 | + bob, _ := s.store.UserByName("bob") | |
| 247 | + if s.store.Account(bob.ID).Email != "b@example.org" { | |
| 248 | + t.Error("required email not stored") | |
| 249 | + } | |
| 250 | +} | |
| 251 | + | |
| 252 | +func TestAdminSections(t *testing.T) { | |
| 253 | + s, h := testServer(t) | |
| 254 | + admin, _ := s.store.CreateUser("root", "correct horse") | |
| 255 | + s.store.SetAdmin("root", true) | |
| 256 | + id, _ := s.store.CreateSession(admin.ID) | |
| 257 | + for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "bogus"} { | |
| 258 | + if w := get(h, "/admin?s="+sec, id); w.Code != 200 { | |
| 259 | + t.Errorf("section %s: %d", sec, w.Code) | |
| 260 | + } | |
| 261 | + } | |
| 262 | + if w := post(h, "/admin", url.Values{"s": {"signup"}, "action": {"signup"}, "mode": {"closed"}, "email": {"required"}}, id); w.Code != http.StatusSeeOther || s.store.Registration() != "closed" || s.store.SignupEmail() != "required" { | |
| 263 | + t.Errorf("signup settings not saved: %d %s %s", w.Code, s.store.Registration(), s.store.SignupEmail()) | |
| 264 | + } | |
| 265 | + if w := post(h, "/admin", url.Values{"s": {"signup"}, "action": {"signup"}, "mode": {"open"}, "email": {"sometimes"}}, id); !strings.Contains(w.Header().Get("Location"), "error") { | |
| 266 | + t.Error("bad email mode accepted") | |
| 267 | + } | |
| 268 | +} | |
mail_test.go modified
| @@ -91,6 +91,7 @@ func TestSignupVerifyAndReset(t *testing.T) { | ||
| 91 | 91 | fake, m := withCloudflare(t) |
| 92 | 92 | s, h := testServer(t) |
| 93 | 93 | s.mailer = m |
| 94 | + s.store.SetSetting("signupemail", "optional") | |
| 94 | 95 | link := regexp.MustCompile(`https://example\.org(/[a-z]+/[0-9a-f]+)`) |
| 95 | 96 | |
| 96 | 97 | w := post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}, "email": {"Alice@Example.org"}}, "") |
static/style.css modified
| @@ -431,12 +431,67 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 431 | 431 | .orgactions{display:flex;gap:.5rem} |
| 432 | 432 | .columns.orgbody{grid-template-columns:minmax(0,1fr) 280px;gap:2rem} |
| 433 | 433 | @media(max-width:800px){.orghead{grid-template-columns:auto minmax(0,1fr)}.orgactions{grid-column:1/-1}.orgmark img,.orgmark span{width:4.5rem;height:4.5rem;font-size:2rem}.columns.orgbody{grid-template-columns:1fr}} |
| 434 | -.admintable{border-collapse:collapse;width:100%;max-width:52rem;margin-bottom:1rem;font-size:.93rem} | |
| 435 | -.admintable th,.admintable td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid var(--line);vertical-align:middle} | |
| 436 | -.admintable th{color:var(--muted);font-weight:600} | |
| 437 | -.admintable input[type=number]{width:4.5rem} | |
| 438 | -.admintable .btn.small{margin:0 .3rem 0 0} | |
| 439 | -.admintable .acts{white-space:nowrap} | |
| 440 | -.codes{list-style:none;padding:0;margin:.6rem 0 1rem} | |
| 441 | -.codes li{margin:.2rem 0} | |
| 442 | 434 | .dashhead span{display:flex;gap:.5rem} |
| 435 | +.admin{display:grid;grid-template-columns:11rem minmax(0,1fr);gap:2rem;align-items:start} | |
| 436 | +.adminnav{position:sticky;top:1rem;display:flex;flex-direction:column;gap:.15rem} | |
| 437 | +.adminnav h3{font-size:.8rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 .5rem .6rem} | |
| 438 | +.adminnav a{padding:.4rem .6rem;color:var(--fg);border-left:2px solid transparent;text-decoration:none} | |
| 439 | +.adminnav a:hover{background:var(--soft)} | |
| 440 | +.adminnav a.active{background:var(--accentsoft);border-left-color:var(--accent);color:var(--accent);font-weight:600} | |
| 441 | +.adminmain h2{font-size:1.45rem;margin:0 0 1rem} | |
| 442 | +.adminmain>p.muted{margin-top:-.4rem;max-width:44rem} | |
| 443 | +.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.8rem;margin-bottom:1.4rem} | |
| 444 | +.tile{display:flex;flex-direction:column;padding:.9rem 1rem;border:1px solid var(--line);color:var(--fg);text-decoration:none} | |
| 445 | +.tile:hover{border-color:var(--accent)} | |
| 446 | +.tile .num{font-size:1.7rem;font-weight:600;line-height:1.1;color:var(--accent)} | |
| 447 | +.tile .what{color:var(--muted);font-size:.9rem} | |
| 448 | +.tile .sub{color:var(--muted);font-size:.78rem;margin-top:.3rem} | |
| 449 | +.panel{border:1px solid var(--line);padding:1rem 1.2rem;margin-bottom:1.2rem;max-width:52rem} | |
| 450 | +.panelhead{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:.7rem} | |
| 451 | +.panelhead h3{margin:0;font-size:1.05rem} | |
| 452 | +.panelhead form{margin:0} | |
| 453 | +.status{display:grid;grid-template-columns:max-content 1fr;gap:.45rem 1.5rem;margin:0} | |
| 454 | +.status dt{color:var(--muted)} | |
| 455 | +.status dd{margin:0} | |
| 456 | +.pill{display:inline-block;padding:0 .45rem;border-radius:999px;background:var(--soft);border:1px solid var(--line);font-size:.75rem;color:var(--muted);vertical-align:middle;line-height:1.5} | |
| 457 | +.pill.open{background:#e6f4ea;border-color:#b7dfc3;color:#1a7f37} | |
| 458 | +.pill.invite{background:var(--accentsoft);border-color:var(--focus);color:var(--accent)} | |
| 459 | +.pill.closed{background:#fdecea;border-color:#f5c2bd;color:var(--del)} | |
| 460 | +.plainlist{list-style:none;margin:0;padding:0} | |
| 461 | +.plainlist li{display:flex;align-items:center;gap:.5rem;padding:.4rem 0;border-bottom:1px solid var(--line)} | |
| 462 | +.plainlist li:last-child{border-bottom:0} | |
| 463 | +.plainlist .muted{margin-left:auto;font-size:.85rem} | |
| 464 | +.filter{display:flex;align-items:center;gap:.5rem;margin-bottom:1rem;max-width:30rem} | |
| 465 | +.filter input{flex:1} | |
| 466 | +.tablewrap{overflow-x:auto;max-width:52rem} | |
| 467 | +.admintable{border-collapse:collapse;width:100%;font-size:.93rem} | |
| 468 | +.admintable th,.admintable td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);vertical-align:middle} | |
| 469 | +.admintable th{color:var(--muted);font-weight:600;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em} | |
| 470 | +.admintable tbody tr:hover{background:var(--soft)} | |
| 471 | +.admintable .n{text-align:right} | |
| 472 | +.admintable .acts{text-align:right;white-space:nowrap} | |
| 473 | +.admintable .acts form{display:inline-flex;gap:.35rem;margin:0} | |
| 474 | +.admintable .btn.small{margin:0} | |
| 475 | +.admintable tr.featuredrow td:first-child{box-shadow:inset 3px 0 var(--accent)} | |
| 476 | +.rankform{display:flex;gap:.35rem;margin:0} | |
| 477 | +.rankform input{width:4.2rem} | |
| 478 | +.choices{border:0;padding:0;margin:0 0 1.2rem} | |
| 479 | +.choices legend{font-weight:600;margin-bottom:.5rem;padding:0} | |
| 480 | +.choice{display:flex;align-items:flex-start;gap:.6rem;padding:.6rem .7rem;border:1px solid var(--line);margin-bottom:.4rem;cursor:pointer} | |
| 481 | +.choice:has(input:checked){border-color:var(--accent);background:var(--accentsoft)} | |
| 482 | +.choice input{width:auto;margin:.25rem 0 0} | |
| 483 | +.choice span{display:flex;flex-direction:column} | |
| 484 | +.choice small{color:var(--muted)} | |
| 485 | +.invites{list-style:none;margin:0;padding:0} | |
| 486 | +.invites li{display:flex;gap:.5rem;align-items:center;margin:.35rem 0} | |
| 487 | +.invites input{flex:1;font-family:var(--mono,monospace);font-size:.85rem} | |
| 488 | +.invites form{margin:0} | |
| 489 | +.quotaform{display:flex;flex-wrap:wrap;align-items:flex-end;gap:.6rem} | |
| 490 | +.quotaform label{display:flex;flex-direction:column;gap:.3rem} | |
| 491 | +.quotaform p{flex-basis:100%;margin:0} | |
| 492 | +.usage{list-style:none;margin:0;padding:0} | |
| 493 | +.usage li{display:grid;grid-template-columns:9rem 1fr 8rem;align-items:center;gap:.8rem;padding:.35rem 0} | |
| 494 | +.usage .muted{text-align:right;font-size:.88rem} | |
| 495 | +.usage .bar{height:.5rem;background:var(--heat0);border-radius:3px;overflow:hidden} | |
| 496 | +.usage .bar i{display:block;height:100%;background:var(--accent)} | |
| 497 | +@media(max-width:800px){.admin{grid-template-columns:1fr}.adminnav{position:static;flex-direction:row;flex-wrap:wrap}.adminnav h3{display:none}.adminnav a{border-left:0;border-bottom:2px solid transparent}.adminnav a.active{border-bottom-color:var(--accent)}.usage li{grid-template-columns:6rem 1fr 6.5rem}} | |
templates/admin.html modified
| @@ -1,22 +1,98 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$d := .Data}} |
| 3 | -<h3 class="rule">Registration</h3> | |
| 4 | -<form method="post" action="/admin" class="stack wide modes"> | |
| 5 | -<input type="hidden" name="action" value="registration"> | |
| 6 | -<label class="radio"><input type="radio" name="mode" value="open"{{if eq $d.Registration "open"}} checked{{end}}> Open<small>Anyone can create an account, a few per address every few minutes</small></label> | |
| 7 | -<label class="radio"><input type="radio" name="mode" value="invite"{{if eq $d.Registration "invite"}} checked{{end}}> Invite only<small>Registration needs a single use code from the list below</small></label> | |
| 8 | -<label class="radio"><input type="radio" name="mode" value="closed"{{if eq $d.Registration "closed"}} checked{{end}}> Closed<small>Nobody can register; accounts are created with <code>gitpocket user</code></small></label> | |
| 9 | -<div class="actions left"><button class="btn primary">Save ▸</button></div> | |
| 3 | +<div class="admin"> | |
| 4 | +<nav class="adminnav" aria-label="Admin sections"> | |
| 5 | +<h3>Admin</h3> | |
| 6 | +{{range $d.Sections}}<a href="/admin?s={{.Key}}"{{if eq .Key $d.Section}} class="active" aria-current="page"{{end}}>{{.Title}}</a>{{end}} | |
| 7 | +</nav> | |
| 8 | +<div class="adminmain"> | |
| 9 | +{{if $d.Saved}}<p class="notice">Saved.</p>{{end}} | |
| 10 | + | |
| 11 | +{{if eq $d.Section "overview"}} | |
| 12 | +<h2>Overview</h2> | |
| 13 | +{{with $d.Stats}}<div class="tiles"> | |
| 14 | +<a class="tile" href="/admin?s=people"><span class="num">{{.Users}}</span><span class="what">users</span>{{if .Banned}}<span class="sub">{{.Banned}} banned</span>{{end}}</a> | |
| 15 | +<a class="tile" href="/admin?s=people"><span class="num">{{.Orgs}}</span><span class="what">organisations</span></a> | |
| 16 | +<a class="tile" href="/admin?s=projects"><span class="num">{{.Repos}}</span><span class="what">repositories</span><span class="sub">{{.Private}} private · {{.Unlisted}} unlisted</span></a> | |
| 17 | +<a class="tile" href="/admin?s=storage"><span class="num">{{size $d.Used}}</span><span class="what">on disk</span><span class="sub">{{if $d.Quota}}quota {{$d.QuotaText}} each{{else}}no quota{{end}}</span></a> | |
| 18 | +</div>{{end}} | |
| 19 | +<section class="panel"> | |
| 20 | +<div class="panelhead"><h3>Sign up</h3><a href="/admin?s=signup">Change</a></div> | |
| 21 | +<dl class="status"> | |
| 22 | +<dt>Registration</dt><dd><span class="pill {{$d.Registration}}">{{if eq $d.Registration "open"}}Open to everyone{{else if eq $d.Registration "invite"}}Invite only{{else}}Closed{{end}}</span></dd> | |
| 23 | +<dt>Email at sign up</dt><dd>{{if eq $d.SignupEmail "off"}}Not asked{{else if eq $d.SignupEmail "optional"}}Optional{{else}}Required{{end}}</dd> | |
| 24 | +<dt>Outgoing mail</dt><dd>{{if $d.Mail}}Configured{{else}}Not configured, resets go through <code>gitpocket passwd</code>{{end}}</dd> | |
| 25 | +</dl> | |
| 26 | +</section> | |
| 27 | +<section class="panel"> | |
| 28 | +<div class="panelhead"><h3>Newest accounts</h3><a href="/admin?s=people">All people</a></div> | |
| 29 | +<ul class="plainlist">{{range $d.Recent}}<li><a href="/~{{.Name}}">~{{.Name}}</a>{{if .Org}} <span class="pill">org</span>{{end}}{{if .Admin}} <span class="pill">admin</span>{{end}}{{if .Banned}} <span class="pill closed">banned</span>{{end}}<span class="muted">{{ago .Created}}</span></li>{{end}}</ul> | |
| 30 | +</section> | |
| 31 | +{{end}} | |
| 32 | + | |
| 33 | +{{if eq $d.Section "people"}} | |
| 34 | +<h2>People</h2> | |
| 35 | +<form method="get" action="/admin" class="filter"><input type="hidden" name="s" value="people"><input name="q" value="{{$d.Query}}" placeholder="Filter by name" aria-label="Filter by name"> <button class="btn">Filter</button>{{if $d.Query}} <a href="/admin?s=people">Clear</a>{{end}}</form> | |
| 36 | +<div class="tablewrap"><table class="admintable"> | |
| 37 | +<thead><tr><th>Account</th><th>Joined</th><th class="n">Repos</th><th></th></tr></thead> | |
| 38 | +<tbody>{{range $d.Accounts}}<tr> | |
| 39 | +<td><a href="/~{{.Name}}">~{{.Name}}</a>{{if .Org}} <span class="pill">org</span>{{end}}{{if .Admin}} <span class="pill">admin</span>{{end}}{{if .Banned}} <span class="pill closed">banned</span>{{end}}</td> | |
| 40 | +<td>{{.Created.Format "2 Jan 2006"}}</td> | |
| 41 | +<td class="n">{{.Repos}}</td> | |
| 42 | +<td class="acts">{{if ne .Name $.User.Name}}<form method="post" action="/admin"><input type="hidden" name="s" value="people"><input type="hidden" name="q" value="{{$d.Query}}"><input type="hidden" name="name" value="{{.Name}}">{{if not .Org}}{{if .Admin}}<button class="btn small" name="action" value="unadmin">Revoke admin</button>{{else}}<button class="btn small" name="action" value="admin">Make admin</button>{{end}}{{end}}{{if .Banned}}<button class="btn small" name="action" value="unban">Unban</button>{{else}}<button class="btn small danger" name="action" value="ban">Ban</button>{{end}}</form>{{else}}<span class="muted small">you</span>{{end}}</td> | |
| 43 | +</tr>{{else}}<tr><td colspan="4" class="muted">Nobody matches.</td></tr>{{end}}</tbody> | |
| 44 | +</table></div> | |
| 45 | +{{end}} | |
| 46 | + | |
| 47 | +{{if eq $d.Section "signup"}} | |
| 48 | +<h2>Sign up</h2> | |
| 49 | +<form method="post" action="/admin" class="panel"> | |
| 50 | +<input type="hidden" name="action" value="signup"><input type="hidden" name="s" value="signup"> | |
| 51 | +<fieldset class="choices"><legend>Who can register</legend> | |
| 52 | +<label class="choice"><input type="radio" name="mode" value="open"{{if eq $d.Registration "open"}} checked{{end}}><span><b>Open</b><small>Anyone can create an account, a few per address every few minutes</small></span></label> | |
| 53 | +<label class="choice"><input type="radio" name="mode" value="invite"{{if eq $d.Registration "invite"}} checked{{end}}><span><b>Invite only</b><small>Registration needs a single use code from below</small></span></label> | |
| 54 | +<label class="choice"><input type="radio" name="mode" value="closed"{{if eq $d.Registration "closed"}} checked{{end}}><span><b>Closed</b><small>Nobody can register, accounts come from <code>gitpocket user</code></small></span></label> | |
| 55 | +</fieldset> | |
| 56 | +<fieldset class="choices"><legend>Email address at sign up</legend> | |
| 57 | +<label class="choice"><input type="radio" name="email" value="off"{{if eq $d.SignupEmail "off"}} checked{{end}}><span><b>Don't ask</b><small>A name and password is all it takes</small></span></label> | |
| 58 | +<label class="choice"><input type="radio" name="email" value="optional"{{if eq $d.SignupEmail "optional"}} checked{{end}}><span><b>Optional</b><small>Shown on the form but can be left empty</small></span></label> | |
| 59 | +<label class="choice"><input type="radio" name="email" value="required"{{if eq $d.SignupEmail "required"}} checked{{end}}><span><b>Required</b><small>Nobody registers without one</small></span></label> | |
| 60 | +<p class="muted small">{{if $d.Mail}}Outgoing mail is configured, so given addresses get a confirmation link and can reset passwords.{{else}}Outgoing mail is not configured, so addresses are stored but never confirmed and nothing is sent.{{end}}</p> | |
| 61 | +</fieldset> | |
| 62 | +<div class="actions left"><button class="btn primary">Save</button></div> | |
| 63 | +</form> | |
| 64 | +<section class="panel"> | |
| 65 | +<div class="panelhead"><h3>Invites</h3><form method="post" action="/admin"><input type="hidden" name="s" value="signup"><button class="btn" name="action" value="invite">New invite</button></form></div> | |
| 66 | +{{if $d.Invites}}<ul class="invites">{{range $d.Invites}}<li><input readonly value="https://{{$.Host}}/register?invite={{.}}" aria-label="Invite link"><form method="post" action="/admin"><input type="hidden" name="s" value="signup"><input type="hidden" name="code" value="{{.}}"><button class="btn small danger" name="action" value="revoke">Revoke</button></form></li>{{end}}</ul>{{else}}<p class="muted">No unused invites.</p>{{end}} | |
| 67 | +</section> | |
| 68 | +{{end}} | |
| 69 | + | |
| 70 | +{{if eq $d.Section "projects"}} | |
| 71 | +<h2>Front page</h2> | |
| 72 | +<p class="muted">Numbered projects appear on the logged out front page in that order. Leave a project at 0 to hide it. With none chosen the most recently pushed are shown.</p> | |
| 73 | +<div class="tablewrap"><table class="admintable"> | |
| 74 | +<thead><tr><th>Repository</th><th>Description</th><th>Position</th></tr></thead> | |
| 75 | +<tbody>{{range $d.Public}}{{$rank := index $d.Ranks .ID}}<tr{{if $rank}} class="featuredrow"{{end}}> | |
| 76 | +<td><a href="/{{.Path}}">{{.Path}}</a></td> | |
| 77 | +<td class="muted">{{.Description}}</td> | |
| 78 | +<td><form method="post" action="/admin" class="rankform"><input type="hidden" name="s" value="projects"><input type="hidden" name="action" value="feature"><input type="hidden" name="name" value="{{.Owner.Name}}/{{.Name}}"><input name="rank" type="number" min="0" max="99" value="{{$rank}}" aria-label="Position for {{.Path}}"><button class="btn small">Set</button></form></td> | |
| 79 | +</tr>{{end}}</tbody> | |
| 80 | +</table></div> | |
| 81 | +{{end}} | |
| 82 | + | |
| 83 | +{{if eq $d.Section "storage"}} | |
| 84 | +<h2>Storage</h2> | |
| 85 | +<form method="post" action="/admin" class="panel quotaform"> | |
| 86 | +<input type="hidden" name="s" value="storage"><input type="hidden" name="action" value="quota"> | |
| 87 | +<label>Quota per owner <input name="quota" value="{{$d.QuotaText}}" placeholder="0"></label> | |
| 88 | +<button class="btn primary">Save</button> | |
| 89 | +<p class="muted small">For example 500M or 2G, and 0 for no limit. A push that would go over it is refused before anything is written.</p> | |
| 10 | 90 | </form> |
| 11 | -<h3 class="rule" id="invites">Invites</h3> | |
| 12 | -<form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="invite"><button class="btn">Create invite ▸</button></form> | |
| 13 | -{{if $d.Invites}}<ul class="codes">{{range $d.Invites}}<li><code>https://{{$.Host}}/register?invite={{.}}</code></li>{{end}}</ul>{{else}}<p class="muted small">No unused invites.</p>{{end}} | |
| 14 | -<h3 class="rule">Disk quota</h3> | |
| 15 | -<form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="quota"><input name="quota" value="{{$d.Quota}}" aria-label="Quota per owner"> <button class="btn">Set ▸</button></form> | |
| 16 | -<p class="muted small">Per owner, for example 500M or 2G. Zero disables the quota. Pushes beyond it are refused before any data is written.</p> | |
| 17 | -<h3 class="rule" id="featured">Featured projects</h3> | |
| 18 | -<table class="admintable"><tbody>{{range $d.Public}}<tr><td><a href="/{{.Path}}">{{.Path}}</a></td><td><form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="feature"><input type="hidden" name="name" value="{{.Owner.Name}}/{{.Name}}"><input name="rank" type="number" min="0" max="99" value="{{index $d.Ranks .ID}}" aria-label="Position"> <button class="btn small">Set</button></form></td></tr>{{end}}</tbody></table> | |
| 19 | -<p class="muted small">Position 1 comes first on the front page and 0 removes a project. With none chosen the most recently pushed are shown.</p> | |
| 20 | -<h3 class="rule" id="accounts">Accounts</h3> | |
| 21 | -<table class="admintable"><thead><tr><th>Name</th><th>Joined</th><th>Repos</th><th></th></tr></thead><tbody>{{range $d.Accounts}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a>{{if .Org}} <span class="muted">org</span>{{end}}{{if .Admin}} <span class="muted">admin</span>{{end}}{{if .Banned}} <span class="muted">banned</span>{{end}}</td><td>{{.Created.Format "2006-01-02"}}</td><td>{{.Repos}}</td><td class="acts">{{if ne .Name $.User.Name}}<form method="post" action="/admin" class="inline"><input type="hidden" name="name" value="{{.Name}}">{{if .Banned}}<button class="btn small" name="action" value="unban">Unban</button>{{else}}<button class="btn small danger" name="action" value="ban">Ban</button>{{end}}{{if not .Org}}{{if .Admin}}<button class="btn small" name="action" value="unadmin">Revoke admin</button>{{else}}<button class="btn small" name="action" value="admin">Make admin</button>{{end}}{{end}}</form>{{end}}</td></tr>{{end}}</tbody></table> | |
| 91 | +<section class="panel"> | |
| 92 | +<div class="panelhead"><h3>Usage by owner</h3></div> | |
| 93 | +<ul class="usage">{{range $d.Owners}}<li><a href="/~{{.Name}}">~{{.Name}}</a><span class="bar"><i style="width:{{.Pct}}%"></i></span><span class="muted">{{size .Used}}{{if $d.Quota}} of {{$d.QuotaText}}{{end}}</span></li>{{else}}<li class="muted">No repositories yet.</li>{{end}}</ul> | |
| 94 | +</section> | |
| 95 | +{{end}} | |
| 96 | +</div> | |
| 97 | +</div> | |
| 22 | 98 | {{end}} |
templates/register.html modified
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="auth"> |
| 3 | 3 | <h3 class="rule">Register for {{.Host}} <small>or <a href="/login">log in</a></small></h3> |
| 4 | -{{if eq .Data.Mode "closed"}}<p class="info">Registration is closed on {{.Host}}. Ask an administrator for an account.</p>{{else}}<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. An email address is optional and you are logged in straight away either way.</p> | |
| 4 | +{{if eq .Data.Mode "closed"}}<p class="info">Registration is closed on {{.Host}}. Ask an administrator for an account.</p>{{else}}<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. {{if eq .Data.AskEmail "off"}}No email is needed{{else if eq .Data.AskEmail "optional"}}An email address is optional{{else}}An email address is needed{{end}} and you are logged in straight away.</p> | |
| 5 | 5 | <form method="post" action="/register" class="stack centred"> |
| 6 | 6 | <label>Username <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}" value="{{.Data.Name}}"><small>Lowercase letters, digits, dot, dash or underscore.</small></label> |
| 7 | 7 | <label>Password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label> |
| 8 | -{{if .Data.Mail}}<label>Email <span class="muted">(optional)</span> <input name="email" type="email" autocomplete="email" maxlength="200" value="{{.Data.Email}}"><small>We send one link to confirm it, after which it can reset your password. Never shown publicly.</small></label>{{end}} | |
| 8 | +{{if ne .Data.AskEmail "off"}}<label>Email{{if eq .Data.AskEmail "optional"}} <span class="muted">(optional)</span>{{end}} <input name="email" type="email" autocomplete="email" maxlength="200" value="{{.Data.Email}}"{{if eq .Data.AskEmail "required"}} required{{end}}><small>{{if .Data.Mail}}We send one link to confirm it, after which it can reset your password. {{end}}Never shown publicly.</small></label>{{end}} | |
| 9 | 9 | {{if eq .Data.Mode "invite"}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}} |
| 10 | 10 | <div class="actions"><button class="btn primary">Register ▸</button></div> |
| 11 | 11 | </form>{{end}} |
web.go modified
| @@ -312,14 +312,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | ||
| 312 | 312 | } |
| 313 | 313 | |
| 314 | 314 | func (s *Server) registerForm(w http.ResponseWriter, r *http.Request) { |
| 315 | - s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite"), "Mail": s.mailer != nil}}) | |
| 315 | + s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite"), "Mail": s.mailer != nil, "AskEmail": s.store.SignupEmail()}}) | |
| 316 | 316 | } |
| 317 | 317 | |
| 318 | 318 | func (s *Server) register(w http.ResponseWriter, r *http.Request) { |
| 319 | 319 | invite := strings.TrimSpace(r.FormValue("invite")) |
| 320 | 320 | retry := func(code int, msg string) { |
| 321 | 321 | w.WriteHeader(code) |
| 322 | - s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite, "Mail": s.mailer != nil, "Email": r.FormValue("email"), "Name": r.FormValue("name")}}) | |
| 322 | + s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite, "Mail": s.mailer != nil, "AskEmail": s.store.SignupEmail(), "Email": r.FormValue("email"), "Name": r.FormValue("name")}}) | |
| 323 | 323 | } |
| 324 | 324 | mode := s.store.Registration() |
| 325 | 325 | if mode == "closed" { |
| @@ -337,7 +337,13 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 337 | 337 | return |
| 338 | 338 | } |
| 339 | 339 | email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) |
| 340 | - if email != "" && !validEmail(email) { | |
| 340 | + switch ask := s.store.SignupEmail(); { | |
| 341 | + case ask == "off": | |
| 342 | + email = "" | |
| 343 | + case ask == "required" && email == "": | |
| 344 | + retry(400, "An email address is required to register here") | |
| 345 | + return | |
| 346 | + case email != "" && !validEmail(email): | |
| 341 | 347 | retry(400, "That does not look like an email address") |
| 342 | 348 | return |
| 343 | 349 | } |
| @@ -1033,7 +1039,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 1033 | 1039 | rp.Tab = "summary" |
| 1034 | 1040 | data := map[string]any{"Page": rp} |
| 1035 | 1041 | if !rp.Empty { |
| 1036 | - commits, _ := g.Log(rp.Commit, 3, "") | |
| 1042 | + commits, _ := g.Log(rp.Commit, 4, "") | |
| 1037 | 1043 | data["Commits"] = commits |
| 1038 | 1044 | branches, _ := g.Refs("refs/heads") |
| 1039 | 1045 | tags, _ := g.Refs("refs/tags") |