~gitpocket/gitpocket

feat(admin): restyle the admin page with the site tabs and tables and require an email on sign up

commit
6d62ad17e750639ddf3d54fcbfc50da2561fe4b5 browse tree
parent
4c1d80270e0f38f1fd0000c635230dacce3fae5b
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 12:27 UTC

6 files changed, +65 −146

README.md modified

@@ -23,7 +23,7 @@ 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, 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
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. The sign up form requires one by default, an admin can make it optional or drop the question, 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

admin.go modified

@@ -1,6 +1,7 @@
11 package main
22
33 import (
4+ "cmp"
45 "net/http"
56 "net/url"
67 "os"
@@ -125,13 +126,13 @@ func (s *Store) Accounts() []account {
125126 }
126127
127128 var adminSections = []struct{ Key, Title string }{
128- {"overview", "Overview"}, {"people", "People"}, {"signup", "Sign up"}, {"projects", "Front page"}, {"storage", "Storage"},
129+ {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"},
129130 }
130131
131132 var signupEmailModes = []string{"off", "optional", "required"}
132133
133134 func (s *Store) SignupEmail() string {
134- return s.Setting("signupemail", "off")
135+ return s.Setting("signupemail", "required")
135136 }
136137
137138 type siteStats struct {
@@ -217,9 +218,6 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
217218 if quota > 0 {
218219 pct = int(min(100, used*100/quota))
219220 }
220- if used > 0 {
221- pct = max(pct, 1)
222- }
223221 owners = append(owners, owner{d.Name(), used, pct})
224222 }
225223 slices.SortFunc(owners, func(a, b owner) int { return int(b.Used - a.Used) })
@@ -242,7 +240,7 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
242240 name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~")
243241 switch r.FormValue("action") {
244242 case "signup":
245- mode, email := r.FormValue("mode"), r.FormValue("email")
243+ mode, email := cmp.Or(r.FormValue("mode"), s.store.Registration()), cmp.Or(r.FormValue("email"), s.store.SignupEmail())
246244 if !validRegistration(mode) || !slices.Contains(signupEmailModes, email) {
247245 fail("Unknown sign up setting")
248246 return

flow_test.go modified

@@ -57,6 +57,7 @@ func session(w *httptest.ResponseRecorder) string {
5757
5858 func TestRegistrationModes(t *testing.T) {
5959 s, h := testServer(t)
60+ s.store.SetSetting("signupemail", "off")
6061 if w := post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}}, ""); w.Code != http.StatusSeeOther || session(w) == "" {
6162 t.Fatalf("open registration failed: %d", w.Code)
6263 }
@@ -228,6 +229,7 @@ func TestWorkplaceLink(t *testing.T) {
228229
229230 func TestSignupEmailSetting(t *testing.T) {
230231 s, h := testServer(t)
232+ s.store.SetSetting("signupemail", "off")
231233 post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}, "email": {"a@example.org"}}, "")
232234 u, _ := s.store.UserByName("alice")
233235 if s.store.Account(u.ID).Email != "" {
@@ -236,7 +238,7 @@ func TestSignupEmailSetting(t *testing.T) {
236238 if strings.Contains(get(h, "/register", "").Body.String(), `name="email"`) {
237239 t.Error("email field shown while off")
238240 }
239- s.store.SetSetting("signupemail", "required")
241+ s.store.db.Exec(`delete from settings where key = 'signupemail'`)
240242 if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}}, ""); w.Code != 400 {
241243 t.Errorf("required email not enforced: %d", w.Code)
242244 }

static/style.css modified

@@ -306,7 +306,7 @@ h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem}
306306 .landing .more .btn{min-width:20rem;text-align:center}
307307 @media (max-width:700px){.featuredgrid{grid-template-columns:1fr}.landing .more .btn{min-width:0;width:100%}}
308308 .readme video,.readme img{max-width:100%;height:auto}
309-.auth{white-space:nowrap}
309+.auth h3{white-space:nowrap}
310310 .pickaxe{display:flex;gap:.5rem;margin:0 0 1rem;flex-wrap:wrap}.pickaxe input{flex:1;min-width:14rem}
311311 .pickaxe select{font:inherit;color:var(--fg);background:#fff;border:1px solid #ced4da;padding:.3rem .4rem}
312312 .feedlink{margin-right:.6rem}
@@ -432,66 +432,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
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}}
434434 .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}}
435+.admintabs{display:inline-flex;margin-bottom:1rem}
436+form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem}
437+table.keys form.inline{margin:0}
438+input.rank{width:4rem}

templates/admin.html modified

@@ -1,98 +1,76 @@
11 {{define "content"}}
22 {{$d := .Data}}
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">
3+<nav class="tabs small admintabs">{{range $d.Sections}}<a href="/admin?s={{.Key}}"{{if eq .Key $d.Section}} class="active"{{end}}>{{.Title}}</a>{{end}}</nav>
94 {{if $d.Saved}}<p class="notice">Saved.</p>{{end}}
105
116 {{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>
7+{{with $d.Stats}}<h3 class="rule">Site</h3>
8+<table class="keys">
9+<tr><td>Users</td><td>{{.Users}}{{if .Banned}} <span class="muted">({{.Banned}} banned)</span>{{end}}</td></tr>
10+<tr><td>Organisations</td><td>{{.Orgs}}</td></tr>
11+<tr><td>Repositories</td><td>{{.Repos}} <span class="muted">({{.Private}} private, {{.Unlisted}} unlisted)</span></td></tr>
12+<tr><td>Disk usage</td><td>{{size $d.Used}}{{if $d.Quota}} <span class="muted">(quota {{$d.QuotaText}} per owner)</span>{{end}}</td></tr>
13+<tr><td>Registration</td><td>{{if eq $d.Registration "open"}}open to everyone{{else if eq $d.Registration "invite"}}invite only{{else}}closed{{end}}, email {{if eq $d.SignupEmail "off"}}not asked{{else}}{{$d.SignupEmail}}{{end}} <a class="muted" href="/admin?s=signup">change</a></td></tr>
14+<tr><td>Mail</td><td>{{if $d.Mail}}configured{{else}}not configured, resets go through <code>gitpocket passwd</code>{{end}}</td></tr>
15+</table>{{end}}
16+<h3 class="rule">Newest accounts</h3>
17+<table class="keys">{{range $d.Recent}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td class="muted">{{if .Org}}organisation{{else if .Admin}}admin{{end}}{{if .Banned}} banned{{end}}</td><td class="when">{{ago .Created}}</td></tr>{{end}}</table>
3118 {{end}}
3219
3320 {{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>
21+<h3 class="rule">People</h3>
22+<form method="get" action="/admin" class="inline"><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>
23+<table class="keys">{{range $d.Accounts}}<tr>
24+<td><a href="/~{{.Name}}">~{{.Name}}</a></td>
25+<td class="muted">{{if .Org}}organisation{{else if .Admin}}admin{{end}}{{if .Banned}} banned{{end}}</td>
26+<td class="muted">{{.Repos}} repos</td>
27+<td class="when">joined {{.Created.Format "2 Jan 2006"}}</td>
28+<td>{{if ne .Name $.User.Name}}<form method="post" action="/admin" class="inline"><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>{{end}}</td>
29+</tr>{{else}}<tr><td class="muted">Nobody matches.</td></tr>{{end}}</table>
4530 {{end}}
4631
4732 {{if eq $d.Section "signup"}}
48-<h2>Sign up</h2>
49-<form method="post" action="/admin" class="panel">
33+<h3 class="rule">Registration</h3>
34+<form method="post" action="/admin" class="stack">
5035 <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>
36+<label class="check"><input type="radio" name="mode" value="open"{{if eq $d.Registration "open"}} checked{{end}}> Open to everyone</label>
37+<label class="check"><input type="radio" name="mode" value="invite"{{if eq $d.Registration "invite"}} checked{{end}}> Invite only</label>
38+<label class="check"><input type="radio" name="mode" value="closed"{{if eq $d.Registration "closed"}} checked{{end}}> Closed, accounts only come from <code>gitpocket user</code></label>
39+<div class="actions left"><button class="btn primary">Save ▸</button></div>
6340 </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>
41+<h3 class="rule">Email on sign up</h3>
42+<form method="post" action="/admin" class="stack">
43+<input type="hidden" name="action" value="signup"><input type="hidden" name="s" value="signup">
44+<label class="check"><input type="radio" name="email" value="required"{{if eq $d.SignupEmail "required"}} checked{{end}}> Required</label>
45+<label class="check"><input type="radio" name="email" value="optional"{{if eq $d.SignupEmail "optional"}} checked{{end}}> Optional</label>
46+<label class="check"><input type="radio" name="email" value="off"{{if eq $d.SignupEmail "off"}} checked{{end}}> Not asked</label>
47+<small>Addresses are not verified at sign up.{{if $d.Mail}} People can confirm theirs later from settings.{{end}}</small>
48+<div class="actions left"><button class="btn primary">Save ▸</button></div>
49+</form>
50+<h3 class="rule">Invites</h3>
51+{{if $d.Invites}}<table class="keys">{{range $d.Invites}}<tr><td><code>https://{{$.Host}}/register?invite={{.}}</code></td><td><form method="post" action="/admin" class="inline"><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></td></tr>{{end}}</table>{{else}}<p class="muted">No unused invites.</p>{{end}}
52+<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="signup"><button class="btn" name="action" value="invite">New invite ▸</button></form>
6853 {{end}}
6954
7055 {{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}}>
56+<h3 class="rule">Front page</h3>
57+<p class="muted small">Numbered projects appear on the logged out front page in that order, 0 hides one. With none chosen the most recently pushed are shown.</p>
58+<table class="keys">{{range $d.Public}}{{$rank := index $d.Ranks .ID}}<tr>
7659 <td><a href="/{{.Path}}">{{.Path}}</a></td>
7760 <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>
61+<td><form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="projects"><input type="hidden" name="action" value="feature"><input type="hidden" name="name" value="{{.Owner.Name}}/{{.Name}}"><input class="rank" name="rank" type="number" min="0" max="99" value="{{$rank}}" aria-label="Position for {{.Path}}"><button class="btn small">Set</button></form></td>
62+</tr>{{end}}</table>
8163 {{end}}
8264
8365 {{if eq $d.Section "storage"}}
84-<h2>Storage</h2>
85-<form method="post" action="/admin" class="panel quotaform">
66+<h3 class="rule">Quota</h3>
67+<form method="post" action="/admin" class="stack">
8668 <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>
69+<label>Per owner <input name="quota" value="{{$d.QuotaText}}" placeholder="0"></label>
70+<small>For example 500M or 2G, 0 for no limit. A push that would go over it is refused before anything is written.</small>
71+<div class="actions left"><button class="btn primary">Save ▸</button></div>
9072 </form>
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>
73+<h3 class="rule">Usage</h3>
74+<table class="keys">{{range $d.Owners}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td>{{size .Used}}</td><td class="muted">{{if $d.Quota}}{{.Pct}}% of {{$d.QuotaText}}{{end}}</td></tr>{{else}}<tr><td class="muted">No repositories yet.</td></tr>{{end}}</table>
9575 {{end}}
96-</div>
97-</div>
9876 {{end}}

templates/register.html modified

@@ -9,6 +9,6 @@
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}}
12-<p class="privacy"><strong>Privacy notice</strong>: {{.Host}} stores your username, a hash of your password, the keys and profile details you choose to add, and the address of each failed login attempt for a few minutes. Nothing is collected for marketing or analytics and nothing is shared with third parties.</p>
12+<p class="privacy"><strong>Privacy notice</strong>: {{.Host}} stores your username, your email address, a hash of your password, the keys and profile details you choose to add, and the address of each failed login attempt for a few minutes. Nothing is collected for marketing or analytics and nothing is shared with third parties.</p>
1313 </div>
1414 {{end}}