~gitpocket/gitpocket

1

feat(admin): split the admin page into sections

commit
68f3a0d8a32712aa6c67a58dede7dec414f00998 browse tree
parent
d1edfe696d112b3ab3b3ad3c0852fd0f09ed32e6
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-10-25 14:39 UTC

8 files changed, +328 −55

README.md modified

@@ -23,12 +23,12 @@ Small and lightweight git server for self hosting. Repositories are served over
2323 - 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
2424 - 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
2525 - 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
2727 - 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
2828 - SSH keys and access tokens live in settings. HTTPS auth is plain basic auth with a token or your password
2929 - Relative links and images in a README resolve against the repository, so a `docs/diagram.png` reference renders rather than breaking
3030 - 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
3232
3333 ## Design
3434

admin.go modified

@@ -1,8 +1,11 @@
11 package main
22
33 import (
4- "html/template"
54 "net/http"
5+ "net/url"
6+ "os"
7+ "path/filepath"
8+ "slices"
69 "strconv"
710 "strings"
811 "time"
@@ -121,6 +124,27 @@ func (s *Store) Accounts() []account {
121124 return out
122125 }
123126
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+
124148 func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
125149 u := s.require(w, r)
126150 if u == nil {
@@ -134,38 +158,104 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
134158 s.adminAction(w, r, u)
135159 return
136160 }
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"
141168 }
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})
147234 }
148235
149236 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)
151239 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)
153241 }
154242 name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~")
155243 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")
160248 return
161249 }
162250 s.store.SetSetting("registration", mode)
251+ s.store.SetSetting("signupemail", email)
163252 case "invite":
164253 if _, err := s.store.CreateInvite(); err != nil {
165254 fail(err.Error())
166255 return
167256 }
168- back += "#invites"
257+ case "revoke":
258+ s.store.RevokeInvite(r.FormValue("code"))
169259 case "quota":
170260 n, err := parseSize(strings.TrimSpace(r.FormValue("quota")))
171261 if err != nil {
@@ -185,7 +275,6 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
185275 fail("No such account")
186276 return
187277 }
188- back += "#accounts"
189278 case "admin", "unadmin":
190279 if name == u.Name {
191280 fail("You cannot change your own admin rights")
@@ -195,7 +284,6 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
195284 fail("No such user")
196285 return
197286 }
198- back += "#accounts"
199287 case "feature":
200288 owner, repo, ok := strings.Cut(name, "/")
201289 rank, _ := strconv.Atoi(r.FormValue("rank"))
@@ -203,12 +291,18 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
203291 fail("No such repository")
204292 return
205293 }
206- back += "#featured"
207294 default:
208295 fail("Unknown action")
209296 return
210297 }
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)
212306 }
213307
214308 func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) {

flow_test.go modified

@@ -225,3 +225,44 @@ func TestWorkplaceLink(t *testing.T) {
225225 t.Error("unknown workplace should be plain text")
226226 }
227227 }
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) {
9191 fake, m := withCloudflare(t)
9292 s, h := testServer(t)
9393 s.mailer = m
94+ s.store.SetSetting("signupemail", "optional")
9495 link := regexp.MustCompile(`https://example\.org(/[a-z]+/[0-9a-f]+)`)
9596
9697 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}
431431 .orgactions{display:flex;gap:.5rem}
432432 .columns.orgbody{grid-template-columns:minmax(0,1fr) 280px;gap:2rem}
433433 @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}
442434 .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 @@
11 {{define "content"}}
22 {{$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>
1090 </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>
2298 {{end}}

templates/register.html modified

@@ -1,11 +1,11 @@
11 {{define "content"}}
22 <div class="auth">
33 <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>
55 <form method="post" action="/register" class="stack centred">
66 <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>
77 <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}}
99 {{if eq .Data.Mode "invite"}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}}
1010 <div class="actions"><button class="btn primary">Register ▸</button></div>
1111 </form>{{end}}

web.go modified

@@ -312,14 +312,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
312312 }
313313
314314 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()}})
316316 }
317317
318318 func (s *Server) register(w http.ResponseWriter, r *http.Request) {
319319 invite := strings.TrimSpace(r.FormValue("invite"))
320320 retry := func(code int, msg string) {
321321 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")}})
323323 }
324324 mode := s.store.Registration()
325325 if mode == "closed" {
@@ -337,7 +337,13 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
337337 return
338338 }
339339 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):
341347 retry(400, "That does not look like an email address")
342348 return
343349 }
@@ -1033,7 +1039,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
10331039 rp.Tab = "summary"
10341040 data := map[string]any{"Page": rp}
10351041 if !rp.Empty {
1036- commits, _ := g.Log(rp.Commit, 3, "")
1042+ commits, _ := g.Log(rp.Commit, 4, "")
10371043 data["Commits"] = commits
10381044 branches, _ := g.Refs("refs/heads")
10391045 tags, _ := g.Refs("refs/tags")