~gitpocket/gitpocket

1

feat(admin): registration modes and admin page

commit
9c9905c70a387e7033f81f13d3a1a54d33320e0a browse tree
parent
123313819a86951e89e943e620e28ffe783cccb7
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-21 18:32 UTC

9 files changed, +338 −17

admin.go added

@@ -0,0 +1,239 @@
1+package main
2+
3+import (
4+ "html/template"
5+ "net/http"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+const adminSchema = `
12+create table if not exists settings (
13+ key text primary key,
14+ value text not null
15+);
16+create table if not exists redirects (
17+ owner text not null,
18+ name text not null,
19+ repo integer not null references repos(id) on delete cascade,
20+ primary key(owner, name)
21+);
22+`
23+
24+var registrationModes = []string{"open", "invite", "closed"}
25+
26+func (s *Store) Setting(key, fallback string) string {
27+ var v string
28+ if err := s.db.QueryRow(`select value from settings where key = ?`, key).Scan(&v); err != nil {
29+ return fallback
30+ }
31+ return v
32+}
33+
34+func (s *Store) SetSetting(key, value string) error {
35+ _, err := s.db.Exec(`insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value`, key, value)
36+ return err
37+}
38+
39+func (s *Store) Registration() string {
40+ return s.Setting("registration", "open")
41+}
42+
43+func validRegistration(mode string) bool {
44+ for _, m := range registrationModes {
45+ if m == mode {
46+ return true
47+ }
48+ }
49+ return false
50+}
51+
52+func (s *Store) IsAdmin(id int64) bool {
53+ var admin bool
54+ s.db.QueryRow(`select admin from users where id = ? and banned = 0`, id).Scan(&admin)
55+ return admin
56+}
57+
58+func (s *Store) SetAdmin(name string, admin bool) error {
59+ res, err := s.db.Exec(`update users set admin = ? where name = ? and org = 0`, admin, name)
60+ if err != nil {
61+ return err
62+ }
63+ if n, _ := res.RowsAffected(); n == 0 {
64+ return ErrNotFound
65+ }
66+ return nil
67+}
68+
69+func (s *Store) AddRedirect(owner, name string, repo int64) {
70+ s.db.Exec(`insert into redirects(owner, name, repo) values (?, ?, ?) on conflict(owner, name) do update set repo = excluded.repo`, owner, name, repo)
71+}
72+
73+func (s *Store) Redirect(owner, name string) (Repo, bool) {
74+ var id int64
75+ if err := s.db.QueryRow(`select repo from redirects where owner = ? and name = ?`, owner, name).Scan(&id); err != nil {
76+ return Repo{}, false
77+ }
78+ repo, err := s.RepoByID(id)
79+ return repo, err == nil
80+}
81+
82+func (s *Store) Invites() []string {
83+ rows, err := s.db.Query(`select code from invites where used = 0 order by rowid desc limit 50`)
84+ if err != nil {
85+ return nil
86+ }
87+ defer rows.Close()
88+ var codes []string
89+ for rows.Next() {
90+ var c string
91+ if rows.Scan(&c) == nil {
92+ codes = append(codes, c)
93+ }
94+ }
95+ return codes
96+}
97+
98+type account struct {
99+ User
100+ Admin bool
101+ Banned bool
102+ Org bool
103+ Repos int
104+}
105+
106+func (s *Store) Accounts() []account {
107+ rows, err := s.db.Query(`select u.id, u.name, u.created, u.admin, u.banned, u.org, (select count(*) from repos r where r.owner = u.id) from users u order by u.created desc limit 1000`)
108+ if err != nil {
109+ return nil
110+ }
111+ defer rows.Close()
112+ var out []account
113+ for rows.Next() {
114+ var a account
115+ var created int64
116+ if rows.Scan(&a.ID, &a.Name, &created, &a.Admin, &a.Banned, &a.Org, &a.Repos) == nil {
117+ a.Created = time.Unix(created, 0)
118+ out = append(out, a)
119+ }
120+ }
121+ return out
122+}
123+
124+func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
125+ u := s.require(w, r)
126+ if u == nil {
127+ return
128+ }
129+ if !s.store.IsAdmin(u.ID) {
130+ s.fail(w, r, 404, "Not found")
131+ return
132+ }
133+ if r.Method == "POST" {
134+ s.adminAction(w, r, u)
135+ return
136+ }
137+ featured, _ := s.store.FeaturedRepos()
138+ ranks := map[int64]int{}
139+ for i, f := range featured {
140+ ranks[f.ID] = i + 1
141+ }
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+ }})
147+}
148+
149+func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
150+ back := "/admin"
151+ fail := func(msg string) {
152+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(msg), http.StatusSeeOther)
153+ }
154+ name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~")
155+ switch r.FormValue("action") {
156+ case "registration":
157+ mode := r.FormValue("mode")
158+ if !validRegistration(mode) {
159+ fail("Unknown registration mode")
160+ return
161+ }
162+ s.store.SetSetting("registration", mode)
163+ case "invite":
164+ if _, err := s.store.CreateInvite(); err != nil {
165+ fail(err.Error())
166+ return
167+ }
168+ back += "#invites"
169+ case "quota":
170+ n, err := parseSize(strings.TrimSpace(r.FormValue("quota")))
171+ if err != nil {
172+ fail(err.Error())
173+ return
174+ }
175+ if err := writeQuota(s.root, n); err != nil {
176+ fail(err.Error())
177+ return
178+ }
179+ case "ban", "unban":
180+ if name == u.Name {
181+ fail("You cannot ban yourself")
182+ return
183+ }
184+ if err := s.store.SetBanned(name, r.FormValue("action") == "ban"); err != nil {
185+ fail("No such account")
186+ return
187+ }
188+ back += "#accounts"
189+ case "admin", "unadmin":
190+ if name == u.Name {
191+ fail("You cannot change your own admin rights")
192+ return
193+ }
194+ if err := s.store.SetAdmin(name, r.FormValue("action") == "admin"); err != nil {
195+ fail("No such user")
196+ return
197+ }
198+ back += "#accounts"
199+ case "feature":
200+ owner, repo, ok := strings.Cut(name, "/")
201+ rank, _ := strconv.Atoi(r.FormValue("rank"))
202+ if !ok || rank < 0 || s.store.SetFeatured(owner, repo, rank) != nil {
203+ fail("No such repository")
204+ return
205+ }
206+ back += "#featured"
207+ default:
208+ fail("Unknown action")
209+ return
210+ }
211+ http.Redirect(w, r, back, http.StatusSeeOther)
212+}
213+
214+func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) {
215+ u := s.require(w, r)
216+ if u == nil {
217+ return
218+ }
219+ if r.Method == "GET" {
220+ s.render(w, r, "neworg", page{Title: "New organisation", User: u})
221+ return
222+ }
223+ name := strings.TrimSpace(r.FormValue("name"))
224+ if s.limiter.blocked("org:" + u.Name) {
225+ s.render(w, r, "neworg", page{Title: "New organisation", User: u, Error: "Too many organisations created recently, try again later", Data: map[string]any{"Name": name}})
226+ return
227+ }
228+ org, err := s.store.CreateOrg(name)
229+ if err == nil {
230+ err = s.store.SetMember(org.ID, u.ID, true)
231+ }
232+ if err != nil {
233+ w.WriteHeader(400)
234+ s.render(w, r, "neworg", page{Title: "New organisation", User: u, Error: err.Error(), Data: map[string]any{"Name": name}})
235+ return
236+ }
237+ s.limiter.fail("org:" + u.Name)
238+ http.Redirect(w, r, "/~"+org.Name+"/settings", http.StatusSeeOther)
239+}

main.go modified

@@ -7,7 +7,6 @@ import (
77 "net/http"
88 "os"
99 "path/filepath"
10- "strconv"
1110 "strings"
1211 "time"
1312 )
@@ -17,10 +16,12 @@ func main() {
1716 flag.Usage = func() {
1817 fmt.Fprintln(os.Stderr, `usage: gitpocket [-root dir] command
1918
20- serve [-listen addr] [-host name] [-name text] [-open] [-ssh-user name] run the web server
19+ serve [-listen addr] [-host name] [-name text] [-ssh-user name] run the web server
2120 keys user home type key sshd AuthorizedKeysCommand
2221 shell user forced command for ssh git access
2322 invite print a single use registration code
23+ registration [open|invite|closed] show or set who may register
24+ admin name | unadmin name grant or revoke site administration
2425 quota [size] show or set the per user disk quota, 0 disables
2526 user name create a user, password read from stdin
2627 passwd name set a user's password, read from stdin
@@ -29,7 +30,7 @@ func main() {
2930 feature owner/repo... | -owner/repo set or clear the featured projects, in order
3031 org name [+user|-user]... create an organisation and add or remove members
3132 mail address send a test message through the configured mailer
32- backup file write a consistent copy of the database`)
33+ backup dir | file.db snapshot the database and bundle every changed repository into dir`)
3334 }
3435 flag.Parse()
3536 args := flag.Args()
@@ -59,7 +60,7 @@ func main() {
5960 if err != nil {
6061 log.Fatal(err)
6162 }
62- if err := os.WriteFile(filepath.Join(*root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644); err != nil {
63+ if err := writeQuota(*root, n); err != nil {
6364 log.Fatal(err)
6465 }
6566 }
@@ -68,6 +69,24 @@ func main() {
6869 } else {
6970 fmt.Println(humanSize(q))
7071 }
72+ case "registration":
73+ store := mustStore(*root)
74+ if len(args) == 2 {
75+ if !validRegistration(args[1]) {
76+ log.Fatal("registration is open, invite or closed")
77+ }
78+ if err := store.SetSetting("registration", args[1]); err != nil {
79+ log.Fatal(err)
80+ }
81+ }
82+ fmt.Println(store.Registration())
83+ case "admin", "unadmin":
84+ if len(args) != 2 {
85+ os.Exit(2)
86+ }
87+ if err := mustStore(*root).SetAdmin(args[1], args[0] == "admin"); err != nil {
88+ log.Fatal("no such user")
89+ }
7190 case "invite":
7291 store := mustStore(*root)
7392 code, err := store.CreateInvite()
@@ -104,7 +123,7 @@ func main() {
104123 if len(args) != 2 {
105124 os.Exit(2)
106125 }
107- if err := mustStore(*root).Backup(args[1]); err != nil {
126+ if err := backup(*root, args[1], 14); err != nil {
108127 log.Fatal(err)
109128 }
110129 case "mail":
@@ -198,13 +217,12 @@ func serve(root string, args []string) {
198217 listen := fs.String("listen", "127.0.0.1:3200", "address to listen on")
199218 host := fs.String("host", "localhost", "public hostname used in clone urls")
200219 name := fs.String("name", "gitpocket", "site name shown in the header")
201- open := fs.Bool("open", false, "allow registration without an invite")
202220 sshUser := fs.String("ssh-user", "git", "unix user that serves git over ssh, shown in clone urls")
203221 fs.Parse(args)
204222 store := mustStore(root)
205223 srv := &http.Server{
206224 Addr: *listen,
207- Handler: newServer(store, root, *host, *name, *open, *sshUser).handler(),
225+ Handler: newServer(store, root, *host, *name, *sshUser).handler(),
208226 ReadHeaderTimeout: 10 * time.Second,
209227 IdleTimeout: 2 * time.Minute,
210228 MaxHeaderBytes: 64 << 10,

quota.go modified

@@ -21,6 +21,7 @@ func readQuota(root string) int64 {
2121
2222 func parseSize(s string) (int64, error) {
2323 s = strings.ToUpper(strings.TrimSpace(s))
24+ s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(s, "B"), "I"))
2425 mult := int64(1)
2526 switch {
2627 case strings.HasSuffix(s, "G"):
@@ -30,7 +31,7 @@ func parseSize(s string) (int64, error) {
3031 case strings.HasSuffix(s, "K"):
3132 mult, s = 1<<10, strings.TrimSuffix(s, "K")
3233 }
33- n, err := strconv.ParseInt(s, 10, 64)
34+ n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
3435 if err != nil || n < 0 {
3536 return 0, errors.New("size must be a number with an optional K, M or G suffix")
3637 }
@@ -62,3 +63,26 @@ func overQuota(root string, owner string) (bool, int64, int64) {
6263 func quotaMessage(used, quota int64) string {
6364 return fmt.Sprintf("disk quota exceeded, %s used of %s", humanSize(used), humanSize(quota))
6465 }
66+
67+func writeQuota(root string, n int64) error {
68+ return os.WriteFile(filepath.Join(root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644)
69+}
70+
71+func pushLimit(root, owner string) []string {
72+ if over, used, quota := overQuota(root, owner); quota > 0 && !over {
73+ return []string{"-c", "receive.maxInputSize=" + strconv.FormatInt(quota-used, 10)}
74+ }
75+ return nil
76+}
77+
78+func compactSize(n int64) string {
79+ for _, u := range []struct {
80+ suffix string
81+ size int64
82+ }{{"G", 1 << 30}, {"M", 1 << 20}, {"K", 1 << 10}} {
83+ if n >= u.size && n%u.size == 0 {
84+ return strconv.FormatInt(n/u.size, 10) + u.suffix
85+ }
86+ }
87+ return strconv.FormatInt(n, 10)
88+}

static/style.css modified

@@ -187,8 +187,9 @@ input[type=file]{border:0;padding:0}
187187 .twocol{display:grid;grid-template-columns:1fr 1fr;gap:2rem;margin-bottom:2rem}
188188 .twocol label{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.8rem}
189189 .twocol .legend{display:block;margin-bottom:.4rem}
190-.twocol label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem}
191-.twocol label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
190+.twocol label.radio,.modes label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem}
191+.twocol label.radio small,.modes label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
192+.modes input[type=radio]{width:auto;margin:0}
192193 h3.plain{border:0;font-size:1.25rem}
193194 .columns.browse{grid-template-columns:minmax(0,2fr) 1fr}
194195 .search input[name=search],.search input[name=q]{width:100%}
@@ -432,3 +433,12 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
432433 .orgactions{display:flex;gap:.5rem}
433434 .columns.orgbody{grid-template-columns:minmax(0,1fr) 280px;gap:2rem}
434435 @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}}
436+.admintable{border-collapse:collapse;width:100%;max-width:52rem;margin-bottom:1rem;font-size:.93rem}
437+.admintable th,.admintable td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid var(--line);vertical-align:middle}
438+.admintable th{color:var(--muted);font-weight:600}
439+.admintable input[type=number]{width:4.5rem}
440+.admintable .btn.small{margin:0 .3rem 0 0}
441+.admintable .acts{white-space:nowrap}
442+.codes{list-style:none;padding:0;margin:.6rem 0 1rem}
443+.codes li{margin:.2rem 0}
444+.dashhead span{display:flex;gap:.5rem}

templates/admin.html added

@@ -0,0 +1,22 @@
1+{{define "content"}}
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>
10+</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>
22+{{end}}

templates/index.html modified

@@ -1,12 +1,12 @@
11 {{define "content"}}
22 {{if .User}}
33 <div class="dash">
4-<section><div class="dashhead"><h3>Your repositories</h3><a class="btn primary" href="/create">New repository</a></div>
4+<section><div class="dashhead"><h3>Your repositories</h3><span><a class="btn" href="/orgs/new">New organisation</a> <a class="btn primary" href="/create">New repository</a></span></div>
55 {{if .Data.Repos}}{{template "repolist" .Data.Repos}}{{else}}<p class="muted">You have no repositories yet.</p>{{end}}
66 {{if .Data.Shared}}<h3>Shared with you</h3>{{template "repolist" .Data.Shared}}{{end}}</section>
77 {{else}}
88 <div class="landing">
9-<p><a class="btn primary" href="/register">Register here ▸</a> or <a class="arrow" href="/login">Log in ▸</a> to get started, or check out some of our featured projects:</p>
9+{{if eq .Registration "closed"}}<p><a class="btn primary" href="/login">Log in ▸</a> or check out some of our featured projects:</p>{{else}}<p><a class="btn primary" href="/register">Register here ▸</a> or <a class="arrow" href="/login">Log in ▸</a> to get started, or check out some of our featured projects:</p>{{end}}
1010 <h3 class="featured">Featured projects</h3>
1111 <div class="featuredgrid">{{range .Data.Featured}}<div class="feature"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{if .Description}}<blockquote>{{.Description}}</blockquote>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a>{{end}}</p>{{end}}</div>{{end}}</div>
1212 <div class="more"><a class="btn" href="/search">More projects ▸</a></div>

templates/layout.html modified

@@ -12,7 +12,7 @@
1212 {{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css">
1313 <script defer src="/static/katex/katex.min.js"></script>
1414 <script defer src="/static/katex/auto-render.min.js"></script>
15-<script>document.addEventListener("DOMContentLoaded",function(){renderMathInElement(document.body,{delimiters:[{left:"\\(",right:"\\)",display:false},{left:"\\[",right:"\\]",display:true}],throwOnError:false})})</script>{{end}}
15+<script defer src="/static/math.js"></script>{{end}}
1616 </head>
1717 <body{{if .Fluid}} class="fluid"{{end}}>
1818 <header>
@@ -21,7 +21,7 @@
2121 <a href="/search">explore</a>
2222 {{if not (and .Data (index .Data "Facets"))}}<form class="navsearch" method="get" action="/search" role="search"><input name="q" placeholder="Search repositories, people, languages" aria-label="Search"></form>{{end}}
2323 <span class="spacer"></span>
24-{{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="auth"><a href="/login">Log in</a> — <a href="/register">Register</a></span>{{end}}
24+{{if .User}}<span>{{if .Admin}}<a href="/admin">admin</a> — {{end}}Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="auth"><a href="/login">Log in</a>{{if ne .Registration "closed"}} — <a href="/register">Register</a>{{end}}</span>{{end}}
2525 </nav>
2626 {{with .Data}}{{with index . "Settings"}}
2727 <nav class="subtabs">

templates/neworg.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+<h3 class="rule">Create an organisation</h3>
3+<p class="muted">An organisation owns repositories on behalf of a group. You become its first member, and every member can push to its repositories, create new ones under it and edit its page.</p>
4+<form method="post" action="/orgs/new" class="stack wide">
5+<label>Name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}" value="{{with .Data}}{{.Name}}{{end}}"><small>Lowercase letters, digits, dot, dash or underscore. It shares the namespace with users.</small></label>
6+<div class="actions left"><button class="btn primary">Create organisation ▸</button></div>
7+</form>
8+{{end}}

templates/register.html modified

@@ -1,13 +1,13 @@
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-<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. No email is needed and you are logged in straight away.</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. No email is needed 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}"><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 not .Data.Open}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}}
8+{{if eq .Data.Mode "invite"}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}}
99 <div class="actions"><button class="btn primary">Register ▸</button></div>
10-</form>
10+</form>{{end}}
1111 <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>
1212 </div>
1313 {{end}}