Commit 8a7afd05e0

8a7afd05e08dcadb8f7ae2c51a46166dfae1faa9

parent: f7e43e4e28

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:30 UTC

web: access, webhooks, rename, transfer, delete and import pages

Repository settings gains access grants with effective access, webhooks
with deliveries and redeliver (secret from the form body, passed on
stdin), rename, and transfer and delete behind a typed path. /new gains
an import form. Parity rows updated.

Ref #296

Layout: unified · split

.gitbay/wiki/Parity.org +10 −11
@@ -203,11 +203,11 @@ rather than the one the web page shows.
203203| protected tags | yes | yes | yes |
204204| require codeowners | yes | yes | yes |
205205| require contexts | yes | yes | no |
206| access grants | yes | no | yes |
207| effective access | yes | no | yes |
208| webhooks | yes | no | yes |
206| access grants | yes | yes | yes |
207| effective access | yes | yes | yes |
208| webhooks | yes | yes | yes |
209209| runners attach, list, detach | yes | yes | n/a |
210| import from a remote | yes | no | yes |
210| import from a remote | yes | yes | yes |
211211| topics, website | yes | yes | yes |
212212| visibility | yes | yes | yes |
213213| archive (read-only flag) | yes | yes | yes |
@@ -231,8 +231,8 @@ rather than the one the web page shows.
231231| job image (ci.yml) | yes | n/a | n/a |
232232| dependency checks on/off | yes | yes | yes |
233233| dependency status | yes | yes | yes |
234| delete, transfer | yes | no | no |
235| rename | yes | no | yes |
234| delete, transfer | yes | yes | no |
235| rename | yes | yes | yes |
236236| release delete | yes | yes | yes |
237237| release asset add | yes | no | n/a |
238238| release asset remove | yes | no | yes |
@@ -490,11 +490,10 @@ so =gitbay mr apply-suggestion= makes and signs it in a clone with the
490490user's own git signing configuration and pushes it. The web shows that
491491command in place of the button.
492492
493Deleting or transferring a repository stays CLI-only on purpose, as
494does deleting an organization and pruning merge request heads (=admin
495mr prune=): each removes or moves what clone URLs point at, and wants a
496typed command rather than a button. Renaming is the exception: the iOS
497client offers it, and the web has no page yet.
493Deleting an organization and pruning merge request heads (=admin mr
494prune=) stay CLI-only for now. Deleting or transferring a repository is a
495settings section on the web and asks for the repository's path to be
496typed first.
498497
499498=n/a= means a surface cannot usefully carry the capability at all —
500499see the archive note above.
.gitbay/wiki/Users.org +4
@@ -219,6 +219,10 @@ and =fork_of= when it is a
219219fork whose parent you can read, so a client draws a toggle rather than
220220two blind buttons. Absent means none.
221221
222On the web, the repository's settings page carries access, webhooks,
223rename, transfer and delete, and =/new= imports from a remote. Delete and
224transfer ask for the repository's path to be typed.
225
222226Pushing is SSH-only. Public repositories are anonymously readable over
223227HTTPS (and =git://= where enabled); private repositories exist only over
224228SSH and answer "not found" to everyone without access.
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- The repository settings page gains access grants and effective access,
10 webhooks (add, remove, deliveries, redeliver; the secret is a form
11 field passed on stdin and never shown again), rename, transfer and
12 delete with typed confirmation. =/new= gains an import form for
13 =repo import= (#296).
914- =web diff set unified|split= and a Diff layout control on the account
1015 page choose how the merge request, commit and compare pages draw a
1116 diff; =?layout=split|unified= overrides it per request. The split
e2e/webhookweb_test.go added +52
@@ -0,0 +1,52 @@
1package e2e
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "net/url"
8 "strings"
9 "testing"
10)
11
12// TestWebhookSecretFromTheSettingsPage adds a webhook from the settings
13// page with a secret. The secret signs deliveries, and appears nowhere on
14// the resulting pages or in the command's listing (#296).
15func TestWebhookSecretFromTheSettingsPage(t *testing.T) {
16 t.Parallel()
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[webhooks]\nallow_local = true\n")
18 aliceKey := inst.newKey(t, "alice")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 alice := inst.login(t, aliceKey)
25 recv := startHookReceiver(t)
26 const secret = "form-secret-9d2f"
27
28 status, body := browserPost(t, alice, inst.base()+"/alice/proj/settings", url.Values{
29 "field": {"webhook-add"}, "url": {"http://" + recv.addr + "/hook"},
30 "events": {"issue.created"}, "secret": {secret},
31 })
32 if status != 200 || strings.Contains(body, secret) || !strings.Contains(body, "signed") {
33 t.Fatalf("add: %d\n%s", status, body)
34 }
35 if _, body = browserGet(t, alice, inst.base()+"/alice/proj/settings"); strings.Contains(body, secret) {
36 t.Fatal("secret on the settings page")
37 }
38 out, _, _ := inst.ssh(t, aliceKey, "", "webhook", "list", "alice/proj", "--json")
39 if strings.Contains(out, secret) {
40 t.Fatalf("secret in webhook list: %s", out)
41 }
42
43 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", "--title", "'hook me'"); code != 0 {
44 t.Fatalf("issue create: %s", errOut)
45 }
46 h := recv.waitN(t, 1)[0]
47 mac := hmac.New(sha256.New, []byte(secret))
48 mac.Write(h.body)
49 if h.signature != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
50 t.Fatalf("HMAC mismatch: %s", h.signature)
51 }
52}
internal/httpd/accounts.go +18
@@ -233,6 +233,24 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
233233 owner = u.Username
234234 }
235235 name := r.FormValue("name")
236 if r.FormValue("field") == "import" {
237 // The token, if any, reaches the command on stdin only.
238 argv := []string{"repo", "import", owner + "/" + name, "--from", strings.TrimSpace(r.FormValue("from"))}
239 if r.FormValue("visibility") == "private" {
240 argv = append(argv, "--private")
241 }
242 var stdin string
243 if tok := strings.TrimSpace(r.FormValue("token")); tok != "" {
244 argv = append(argv, "--token-stdin")
245 stdin = tok + "\n"
246 }
247 if msg, ok := s.runControlStdin(u, argv, stdin); !ok {
248 s.renderNewRepo(w, u, msg)
249 return
250 }
251 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
252 return
253 }
236254 argv := []string{"repo", "create", owner + "/" + name}
237255 if r.FormValue("visibility") == "private" {
238256 argv = append(argv, "--private")
internal/httpd/settings.go +108 −4
@@ -13,9 +13,8 @@ import (
1313)
1414
1515// Repository settings for repo admins. Every control dispatches the
16// command the CLI runs; the page only groups them. Destructive lifecycle
17// — delete and transfer — stays on the CLI, where a typed confirmation
18// is the norm.
16// command the CLI runs; the page only groups them. Delete and transfer
17// ask for the repository's path to be typed first.
1918
2019type settingsPage struct {
2120 repoPage
@@ -24,12 +23,38 @@ type settingsPage struct {
2423 DepsEnabled bool
2524 Deps control.DepsOut
2625 Runners []store.RepoRunner
26 Access []accessRow
27 Hooks []hookRow
28 Deliveries []deliveryRow
2729 Notice string
2830 Saved bool
2931 Reauth bool // Notice is the stale-session refusal: link to sign in
3032 Submitted map[string]string
3133}
3234
35type accessRow struct {
36 User string `json:"user"`
37 Role string `json:"role"`
38 Source string `json:"source"`
39}
40
41type hookRow struct {
42 ID int64 `json:"id"`
43 URL string `json:"url"`
44 Events string `json:"events"`
45 Secret bool `json:"has_secret"`
46}
47
48type deliveryRow struct {
49 ID int64 `json:"id"`
50 URL string `json:"url"`
51 Event string `json:"event"`
52 Status string `json:"status"`
53 Attempts int `json:"attempts"`
54 LastStatus int `json:"last_status"`
55 LastError string `json:"last_error"`
56}
57
3358func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
3459 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
3560}
@@ -56,6 +81,12 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
5681 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
5782 var runners []store.RepoRunner
5883 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
84 var access []accessRow
85 s.runControlInto(u, []string{"repo", "access", "list", repo.Path()}, &access)
86 var hooks []hookRow
87 s.runControlInto(u, []string{"webhook", "list", repo.Path()}, &hooks)
88 var deliveries []deliveryRow
89 s.runControlInto(u, []string{"webhook", "deliveries", repo.Path(), "--limit", "20"}, &deliveries)
5990 var subm map[string]string
6091 if submitted != nil {
6192 subm = map[string]string{
@@ -63,12 +94,19 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
6394 "website": submitted.Get("website"),
6495 "topics": submitted.Get("topics"),
6596 "key": submitted.Get("key"),
97 "user": submitted.Get("user"),
98 "role": submitted.Get("role"),
99 "url": submitted.Get("url"),
100 "events": submitted.Get("events"),
101 "name": submitted.Get("name"),
102 "new-owner": submitted.Get("new-owner"),
66103 }
67104 }
68105 s.render(w, "settings.html", settingsPage{
69106 repoPage: p, Topics: topics, Branches: branches,
70107 DepsEnabled: deps.Enabled, Deps: deps,
71 Runners: runners,
108 Runners: runners,
109 Access: access, Hooks: hooks, Deliveries: deliveries,
72110 Notice: notice,
73111 Saved: strings.HasPrefix(notice, "Saved "),
74112 Reauth: s.reauthNotice(w, notice, r.URL.Path),
@@ -201,6 +239,68 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
201239 return
202240 case "runner-remove":
203241 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
242 case "access-grant":
243 argv = []string{"repo", "access", "grant", repo, v("user"), v("role")}
244 case "access-revoke":
245 argv = []string{"repo", "access", "revoke", repo, v("user")}
246 case "webhook-add":
247 // The secret goes to the command on stdin and nowhere else: not
248 // argv, not the re-rendered form, not the notice.
249 argv = []string{"webhook", "add", repo, v("url")}
250 if ev := strings.ReplaceAll(v("events"), " ", ""); ev != "" {
251 argv = append(argv, "--events", ev)
252 }
253 var stdin string
254 if secret := strings.TrimRight(r.FormValue("secret"), "\r\n"); secret != "" {
255 argv = append(argv, "--secret", "-")
256 stdin = secret + "\n"
257 }
258 msg, ok := s.runControlStdin(u, argv, stdin)
259 if !ok {
260 s.settingsFormWith(w, r, u, msg, r.Form)
261 return
262 }
263 s.settingsRedirect(w, r, "Saved the webhook.")
264 return
265 case "webhook-remove":
266 argv = []string{"webhook", "remove", repo, v("id")}
267 case "webhook-redeliver":
268 if _, msg, ok := s.runControl(u, []string{"webhook", "redeliver", repo, v("delivery")}); !ok {
269 s.settingsFormWith(w, r, u, msg, r.Form)
270 return
271 }
272 s.settingsRedirect(w, r, "Queued the delivery again.")
273 return
274 case "rename":
275 if _, msg, ok := s.runControl(u, []string{"repo", "rename", repo, v("name")}); !ok {
276 s.settingsFormWith(w, r, u, msg, r.Form)
277 return
278 }
279 s.setFlash(w, "Saved the name.")
280 http.Redirect(w, r, "/"+r.PathValue("owner")+"/"+v("name")+"/settings", http.StatusSeeOther)
281 return
282 case "transfer":
283 if ok, msg := confirmed(r, repo); !ok {
284 s.settingsFormWith(w, r, u, msg, r.Form)
285 return
286 }
287 if _, msg, ok := s.runControl(u, []string{"repo", "transfer", repo, v("new-owner")}); !ok {
288 s.settingsFormWith(w, r, u, msg, r.Form)
289 return
290 }
291 http.Redirect(w, r, "/"+v("new-owner")+"/"+r.PathValue("repo"), http.StatusSeeOther)
292 return
293 case "delete":
294 if ok, msg := confirmed(r, repo); !ok {
295 s.settingsFormWith(w, r, u, msg, r.Form)
296 return
297 }
298 if _, msg, ok := s.runControl(u, []string{"repo", "delete", repo, "--yes"}); !ok {
299 s.settingsFormWith(w, r, u, msg, r.Form)
300 return
301 }
302 http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther)
303 return
204304 default:
205305 s.settingsRedirect(w, r, "unknown setting")
206306 return
@@ -255,6 +355,10 @@ func fieldLabel(field string) string {
255355 return "topics"
256356 case "runner-add", "runner-remove":
257357 return "runner"
358 case "access-grant", "access-revoke":
359 return "access"
360 case "webhook-remove":
361 return "webhook"
258362 default:
259363 return field
260364 }
internal/httpd/settingsparity_test.go added +268
@@ -0,0 +1,268 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/store"
16)
17
18type settingsEnv struct {
19 s *Server
20 st *store.Store
21 alice store.User
22 bob store.User
23 repo store.Repo
24}
25
26func newSettingsEnv(t *testing.T) *settingsEnv {
27 t.Helper()
28 st, err := store.Open(":memory:")
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { st.Close() })
33 if err := st.MigrateUp(); err != nil {
34 t.Fatal(err)
35 }
36 aid, _ := st.CreateUser("alice", false)
37 bid, _ := st.CreateUser("bob", false)
38 if _, err := st.CreateRepo("user", aid, "app", "public"); err != nil {
39 t.Fatal(err)
40 }
41 repo, err := st.RepoByPath("alice/app")
42 if err != nil {
43 t.Fatal(err)
44 }
45 if err := st.GrantAccess(repo.ID, bid, "read"); err != nil {
46 t.Fatal(err)
47 }
48 cfg := config.Default()
49 cfg.Web.Mode = "accounts"
50 cfg.Server.Root = t.TempDir()
51 cfg.Webhooks.AllowLocal = true
52 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
53 t.Fatal(err)
54 }
55 now := time.Now()
56 return &settingsEnv{
57 s: New(cfg, st, nil), st: st, repo: repo,
58 alice: store.User{ID: aid, Username: "alice", SignedInAt: now},
59 bob: store.User{ID: bid, Username: "bob", SignedInAt: now},
60 }
61}
62
63func (e *settingsEnv) post(u store.User, form url.Values) *httptest.ResponseRecorder {
64 req := httptest.NewRequest("POST", "/alice/app/settings", strings.NewReader(form.Encode()))
65 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
66 req.SetPathValue("owner", "alice")
67 req.SetPathValue("repo", "app")
68 rr := httptest.NewRecorder()
69 e.s.settingsSubmit(rr, req, u)
70 return rr
71}
72
73func (e *settingsEnv) page(u store.User) *httptest.ResponseRecorder {
74 req := httptest.NewRequest("GET", "/alice/app/settings", nil)
75 req.SetPathValue("owner", "alice")
76 req.SetPathValue("repo", "app")
77 rr := httptest.NewRecorder()
78 e.s.settingsForm(rr, req, u)
79 return rr
80}
81
82func TestSettingsAccessGrantRevoke(t *testing.T) {
83 e := newSettingsEnv(t)
84 cid, _ := e.st.CreateUser("carol", false)
85 rr := e.post(e.alice, url.Values{"field": {"access-grant"}, "user": {"carol"}, "role": {"write"}})
86 if rr.Code != http.StatusSeeOther {
87 t.Fatalf("grant: %d %s", rr.Code, rr.Body.String())
88 }
89 if role, _ := e.st.AccessRole(e.repo.ID, cid); role != "write" {
90 t.Fatalf("role %q", role)
91 }
92 body := e.page(e.alice).Body.String()
93 for _, want := range []string{"carol", "direct", `value="access-revoke"`, "owner"} {
94 if !strings.Contains(body, want) {
95 t.Errorf("page lacks %q", want)
96 }
97 }
98 rr = e.post(e.alice, url.Values{"field": {"access-revoke"}, "user": {"carol"}})
99 if rr.Code != http.StatusSeeOther {
100 t.Fatalf("revoke: %d %s", rr.Code, rr.Body.String())
101 }
102 if role, _ := e.st.AccessRole(e.repo.ID, cid); role != "" {
103 t.Fatalf("still has %q", role)
104 }
105}
106
107func TestSettingsAccessRefusalShown(t *testing.T) {
108 e := newSettingsEnv(t)
109 rr := e.post(e.alice, url.Values{"field": {"access-grant"}, "user": {"nobody"}, "role": {"read"}})
110 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "no such user &#34;nobody&#34;") {
111 t.Fatalf("%d %s", rr.Code, rr.Body.String())
112 }
113}
114
115func TestSettingsWebhookSecretStaysOffThePage(t *testing.T) {
116 e := newSettingsEnv(t)
117 const secret = "s3cr3t-value-xyz"
118 rr := e.post(e.alice, url.Values{"field": {"webhook-add"}, "url": {"http://127.0.0.1:9/hook"},
119 "events": {"push"}, "secret": {secret}})
120 if rr.Code != http.StatusSeeOther {
121 t.Fatalf("add: %d %s", rr.Code, rr.Body.String())
122 }
123 hooks, _ := e.st.ListWebhooks(e.repo.ID)
124 if len(hooks) != 1 || hooks[0].Secret != secret || hooks[0].Events != "push" {
125 t.Fatalf("stored %+v", hooks)
126 }
127 if strings.Contains(rr.Header().Get("Location"), secret) || strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), ";"), secret) {
128 t.Fatal("secret in redirect or flash")
129 }
130 body := e.page(e.alice).Body.String()
131 if strings.Contains(body, secret) || !strings.Contains(body, "signed") || !strings.Contains(body, "127.0.0.1:9/hook") {
132 t.Fatalf("page: %s", body)
133 }
134
135 // A refused add re-renders the form without the secret.
136 rr = e.post(e.alice, url.Values{"field": {"webhook-add"}, "url": {"ftp://x"}, "events": {"push"}, "secret": {secret}})
137 if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), secret) {
138 t.Fatalf("refusal: %d, secret echoed: %v", rr.Code, strings.Contains(rr.Body.String(), secret))
139 }
140 if !strings.Contains(rr.Body.String(), `role="alert"`) {
141 t.Fatal("no error shown")
142 }
143
144 rr = e.post(e.alice, url.Values{"field": {"webhook-remove"}, "id": {"1"}})
145 if rr.Code != http.StatusSeeOther {
146 t.Fatalf("remove: %d %s", rr.Code, rr.Body.String())
147 }
148 if hooks, _ := e.st.ListWebhooks(e.repo.ID); len(hooks) != 0 {
149 t.Fatalf("still %+v", hooks)
150 }
151}
152
153func TestSettingsWebhookRedeliver(t *testing.T) {
154 e := newSettingsEnv(t)
155 rr := e.post(e.alice, url.Values{"field": {"webhook-redeliver"}, "delivery": {"99"}})
156 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "no delivery 99") {
157 t.Fatalf("%d %s", rr.Code, rr.Body.String())
158 }
159}
160
161func TestSettingsRename(t *testing.T) {
162 e := newSettingsEnv(t)
163 rr := e.post(e.alice, url.Values{"field": {"rename"}, "name": {"Bad Name"}})
164 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `role="alert"`) {
165 t.Fatalf("refusal: %d", rr.Code)
166 }
167 rr = e.post(e.alice, url.Values{"field": {"rename"}, "name": {"tool"}})
168 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/tool/settings" {
169 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
170 }
171 if _, err := os.Stat(control.RepoDir(e.s.cfg.Server.Root, "alice", "tool")); err != nil {
172 t.Fatal(err)
173 }
174}
175
176func TestSettingsDeleteNeedsTypedPath(t *testing.T) {
177 e := newSettingsEnv(t)
178 rr := e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"app"}})
179 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "type alice/app to confirm") {
180 t.Fatalf("%d %s", rr.Code, rr.Body.String())
181 }
182 if _, err := e.st.RepoByPath("alice/app"); err != nil {
183 t.Fatal("deleted without confirmation")
184 }
185 rr = e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
186 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice" {
187 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
188 }
189 if _, err := e.st.RepoByPath("alice/app"); err == nil {
190 t.Fatal("not deleted")
191 }
192}
193
194func TestSettingsTransfer(t *testing.T) {
195 e := newSettingsEnv(t)
196 if _, msg, ok := e.s.runControl(e.alice, []string{"org", "create", "krz"}); !ok {
197 t.Fatal(msg)
198 }
199 rr := e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}})
200 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "type alice/app to confirm") {
201 t.Fatalf("unconfirmed: %d", rr.Code)
202 }
203 rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"nowhere"}, "confirm": {"alice/app"}})
204 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "cannot transfer to &#34;nowhere&#34;") {
205 t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String())
206 }
207 rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}, "confirm": {"alice/app"}})
208 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/krz/app" {
209 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
210 }
211 if _, err := e.st.RepoByPath("krz/app"); err != nil {
212 t.Fatal(err)
213 }
214}
215
216// Only a repository admin reaches the page or the forms; a reader is
217// refused before any command runs.
218func TestSettingsNonAdminSeesNoForms(t *testing.T) {
219 e := newSettingsEnv(t)
220 if rr := e.page(e.bob); rr.Code != http.StatusForbidden || strings.Contains(rr.Body.String(), "webhook-add") {
221 t.Fatalf("page: %d", rr.Code)
222 }
223 for _, form := range []url.Values{
224 {"field": {"webhook-add"}, "url": {"http://127.0.0.1:9/h"}},
225 {"field": {"access-grant"}, "user": {"bob"}, "role": {"admin"}},
226 {"field": {"delete"}, "confirm": {"alice/app"}},
227 {"field": {"rename"}, "name": {"x"}},
228 } {
229 if rr := e.post(e.bob, form); rr.Code != http.StatusForbidden {
230 t.Errorf("%v: %d", form, rr.Code)
231 }
232 }
233 if hooks, _ := e.st.ListWebhooks(e.repo.ID); len(hooks) != 0 {
234 t.Fatal("a reader added a webhook")
235 }
236 if role, _ := e.st.AccessRole(e.repo.ID, e.bob.ID); role != "read" {
237 t.Fatalf("role became %q", role)
238 }
239 if _, err := e.st.RepoByPath("alice/app"); err != nil {
240 t.Fatal("a reader deleted it")
241 }
242 body := e.page(e.alice).Body.String()
243 for _, want := range []string{`value="webhook-add"`, `value="access-grant"`, `value="rename"`, `value="transfer"`, `value="delete"`} {
244 if !strings.Contains(body, want) {
245 t.Errorf("admin page lacks %s", want)
246 }
247 }
248}
249
250func TestNewImportRefusalShown(t *testing.T) {
251 e := newSettingsEnv(t)
252 form := url.Values{"field": {"import"}, "owner": {"alice"}, "name": {"copy"},
253 "from": {"https://user:pw@example.org/r.git"}, "token": {"tok-abc"}}
254 req := httptest.NewRequest("POST", "/new", strings.NewReader(form.Encode()))
255 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
256 rr := httptest.NewRecorder()
257 e.s.newSubmit(rr, req, e.alice)
258 body := rr.Body.String()
259 if rr.Code != http.StatusOK || !strings.Contains(body, "do not embed credentials in the URL") {
260 t.Fatalf("%d %s", rr.Code, body)
261 }
262 if strings.Contains(body, "tok-abc") {
263 t.Fatal("token echoed")
264 }
265 if !strings.Contains(body, `name="field" value="import"`) {
266 t.Fatal("import form missing")
267 }
268}
internal/web/static/style.css +2 −2
@@ -787,7 +787,7 @@ form.setform {
787787}
788788form.setform label { margin-top: 6px; }
789789form.setform .hint { margin: 2px 0 0; }
790form.setform input[type="text"], form.setform select { width: 100%; }
790form.setform input[type="text"], form.setform input[type="password"], form.setform select { width: 100%; }
791791/* a control narrower than its column is pushed to the column's end,
792792 which is where a full-width input's own right edge lands */
793793form.setform .check { justify-content: flex-end; }
@@ -796,7 +796,7 @@ form.setform .num input[type="number"] { width: auto; }
796796/* the third column takes the leftover width; the button keeps its own */
797797form.setform > button, form.setform > .btngroup { justify-self: start; }
798798form.setform.stack { grid-template-columns: 1fr; }
799form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; }
799form.setform.stack textarea, form.setform.stack select, form.setform.stack input[type="text"], form.setform.stack input[type="password"] { width: 100%; max-width: 48rem; }
800800ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; }
801801ul.protlist li {
802802 display: flex;
internal/web/templates/new.html +21
@@ -21,6 +21,27 @@
2121<p><button type="submit">Create repository</button></p>
2222</form>
2323
24<h2 id="import">Import</h2>
25<p class="meta">Copies a repository from another host, over http or https. A private source needs an access token; it is sent to the remote for this one fetch and not stored.</p>
26<form method="post" action="/new" autocomplete="off">
27<input type="hidden" name="field" value="import">
28<p><label>Owner <select name="owner">
29 <option value="{{.Viewer}}">{{.Viewer}}</option>
30 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}}
31</select></label>
32<label>/ Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" autocomplete="off" spellcheck="false"></label></p>
33<p><label>From <input type="text" name="from" required placeholder="https://github.com/owner/repo.git" spellcheck="false" size="48"></label></p>
34<p><label>Access token <input type="password" name="token" autocomplete="new-password"></label> <span class="hint">Optional.</span></p>
35<fieldset class="segmented">
36 <legend>Visibility</legend>
37 <div class="options">
38 <label><input type="radio" name="visibility" value="public" checked><span>Public</span></label>
39 <label><input type="radio" name="visibility" value="private"><span>Private</span></label>
40 </div>
41</fieldset>
42<p><button type="submit">Import repository</button></p>
43</form>
44
2445<h2 id="org">Organization</h2>
2546<p class="meta">An organization owns repositories, labels and milestones, and
2647grants access through teams. You are its first admin.</p>
internal/web/templates/settings.html +83 −2
@@ -12,6 +12,7 @@
1212 <ul>
1313 <li><a href="#identity">Identity</a></li>
1414 <li><a href="#access">Access</a></li>
15 <li><a href="#webhooks">Webhooks</a></li>
1516 <li><a href="#gates">Merge gates</a></li>
1617 <li><a href="#branches">Protected branches</a></li>
1718 <li><a href="#tags">Protected tags</a></li>
@@ -66,6 +67,68 @@
6667 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div>
6768 <div><button type="submit" class="btn">Save</button></div>
6869</form>
70<h3>Who can reach this repository</h3>
71{{if .Access}}<div class="tablewrap"><table class="keys">
72<thead><tr class="cols"><th scope="col">user</th><th scope="col">role</th><th scope="col">via</th><th scope="col"><span class="vh">actions</span></th></tr></thead>
73<tbody>{{range .Access}}<tr>
74 <td class="mono"><a href="/{{.User}}">{{.User}}</a></td>
75 <td>{{.Role}}</td>
76 <td>{{.Source}}</td>
77 <td class="act">{{if eq .Source "direct"}}<form method="post" action="{{$base}}" class="inline">
78 <input type="hidden" name="field" value="access-revoke">
79 <input type="hidden" name="user" value="{{.User}}">
80 <button type="submit" class="danger">Revoke</button>
81 </form>{{end}}</td>
82</tr>{{end}}</tbody></table></div>
83{{else}}<p class="meta">Nobody else can reach it.</p>{{end}}
84<form method="post" action="{{$base}}" class="setform">
85 <input type="hidden" name="field" value="access-grant">
86 <div><label for="grant-user">Grant access</label><p class="hint">An account name and a role. A grant adds to what an organization or team already gives.</p></div>
87 <div><input type="text" id="grant-user" name="user" value="{{index .Submitted "user"}}" autocomplete="off" spellcheck="false" placeholder="username">
88 <select name="role" aria-label="Role">{{$role := index .Submitted "role"}}<option value="read"{{if eq $role "read"}} selected{{end}}>read</option><option value="write"{{if eq $role "write"}} selected{{end}}>write</option><option value="admin"{{if eq $role "admin"}} selected{{end}}>admin</option></select></div>
89 <div><button type="submit" class="btn">Grant</button></div>
90</form>
91</section>
92
93<section id="webhooks"><h2>Webhooks</h2>
94{{if .Hooks}}
95<ul class="protlist">
96{{range .Hooks}}<li><span class="mono">#{{.ID}}</span> <code>{{.URL}}</code> <span class="meta">{{.Events}}{{if .Secret}}, signed{{end}}</span>
97 <form method="post" action="{{$base}}" class="inline">
98 <input type="hidden" name="field" value="webhook-remove">
99 <input type="hidden" name="id" value="{{.ID}}">
100 <button type="submit" class="danger">Remove</button>
101 </form></li>
102{{end}}
103</ul>
104{{else}}<p class="meta">No webhooks.</p>{{end}}
105<form method="post" action="{{$base}}" class="setform stack" autocomplete="off">
106 <input type="hidden" name="field" value="webhook-add">
107 <label for="hook-url">Add a webhook</label>
108 <input type="text" id="hook-url" name="url" value="{{index .Submitted "url"}}" placeholder="https://ci.example.org/hook" spellcheck="false">
109 <label for="hook-events">Events</label>
110 <p class="hint">Comma separated, or <code>*</code> for all.</p>
111 <input type="text" id="hook-events" name="events" value="{{or (index .Submitted "events") "*"}}" spellcheck="false">
112 <label for="hook-secret">Secret</label>
113 <p class="hint">Optional. Signs each delivery; it is not shown again.</p>
114 <input type="password" id="hook-secret" name="secret" value="" autocomplete="new-password">
115 <button type="submit" class="btn">Add</button>
116</form>
117<h3>Recent deliveries</h3>
118{{if .Deliveries}}<div class="tablewrap"><table class="keys">
119<thead><tr class="cols"><th scope="col">id</th><th scope="col">event</th><th scope="col">url</th><th scope="col">state</th><th scope="col"><span class="vh">actions</span></th></tr></thead>
120<tbody>{{range .Deliveries}}<tr>
121 <td class="mono">{{.ID}}</td>
122 <td>{{.Event}}</td>
123 <td class="mono">{{.URL}}</td>
124 <td>{{.Status}} ({{.Attempts}} attempts){{if .LastError}}<br><span class="meta">{{.LastError}}</span>{{end}}</td>
125 <td class="act"><form method="post" action="{{$base}}" class="inline">
126 <input type="hidden" name="field" value="webhook-redeliver">
127 <input type="hidden" name="delivery" value="{{.ID}}">
128 <button type="submit" class="btn">Redeliver</button>
129 </form></td>
130</tr>{{end}}</tbody></table></div>
131{{else}}<p class="meta">Nothing delivered yet.</p>{{end}}
69132</section>
70133
71134<section id="gates"><h2>Merge gates</h2>
@@ -204,8 +267,26 @@
204267 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div>
205268 <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div>
206269</form>
207<p class="meta">Deleting or transferring a repository is a CLI operation:
208<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
270<form method="post" action="{{$base}}" class="setform">
271 <input type="hidden" name="field" value="rename">
272 <div><label for="rename">Name</label><p class="hint">Clone URLs change. The old path stops resolving.</p></div>
273 <div><input type="text" id="rename" name="name" value="{{or (index .Submitted "name") .Repo.Name}}" autocomplete="off" spellcheck="false"></div>
274 <div><button type="submit" class="btn">Rename</button></div>
275</form>
276{{$path := printf "%s/%s" .Repo.OwnerName .Repo.Name}}
277<form method="post" action="{{$base}}" class="setform stack">
278 <input type="hidden" name="field" value="transfer">
279 <label for="new-owner">Transfer</label>
280 <p class="hint">Move it to your own account or an organization you administer. Clone URLs change.</p>
281 <input type="text" id="new-owner" name="new-owner" value="{{index .Submitted "new-owner"}}" placeholder="new owner" autocomplete="off" spellcheck="false">
282 <p>{{template "confirmfield" $path}} <button type="submit" class="danger">Transfer</button></p>
283</form>
284<form method="post" action="{{$base}}" class="setform stack">
285 <input type="hidden" name="field" value="delete">
286 <span class="fieldname">Delete</span>
287 <p class="hint">Removes the repository, its issues and merge requests. Cannot be undone.</p>
288 <p>{{template "confirmfield" $path}} <button type="submit" class="danger">Delete repository</button></p>
289</form>
209290</section>
210291</div>
211292</div>