Commit 8a7afd05e0
Verified · cmc
Layout: unified · split
.gitbay/wiki/Parity.org +10 −11
| @@ -203,11 +203,11 @@ rather than the one the web page shows. | |||
| 203 | | protected tags | yes | yes | yes | | 203 | | protected tags | yes | yes | yes | |
| 204 | | require codeowners | yes | yes | yes | | 204 | | require codeowners | yes | yes | yes | |
| 205 | | require contexts | yes | yes | no | | 205 | | require contexts | yes | yes | no | |
| 206 | | access grants | yes | no | yes | | 206 | | access grants | yes | yes | yes | |
| 207 | | effective access | yes | no | yes | | 207 | | effective access | yes | yes | yes | |
| 208 | | webhooks | yes | no | yes | | 208 | | webhooks | yes | yes | yes | |
| 209 | | runners attach, list, detach | yes | yes | n/a | | 209 | | runners attach, list, detach | yes | yes | n/a | |
| 210 | | import from a remote | yes | no | yes | | 210 | | import from a remote | yes | yes | yes | |
| 211 | | topics, website | yes | yes | yes | | 211 | | topics, website | yes | yes | yes | |
| 212 | | visibility | yes | yes | yes | | 212 | | visibility | yes | yes | yes | |
| 213 | | archive (read-only flag) | yes | yes | yes | | 213 | | archive (read-only flag) | yes | yes | yes | |
| @@ -231,8 +231,8 @@ rather than the one the web page shows. | |||
| 231 | | job image (ci.yml) | yes | n/a | n/a | | 231 | | job image (ci.yml) | yes | n/a | n/a | |
| 232 | | dependency checks on/off | yes | yes | yes | | 232 | | dependency checks on/off | yes | yes | yes | |
| 233 | | dependency status | yes | yes | yes | | 233 | | dependency status | yes | yes | yes | |
| 234 | | delete, transfer | yes | no | no | | 234 | | delete, transfer | yes | yes | no | |
| 235 | | rename | yes | no | yes | | 235 | | rename | yes | yes | yes | |
| 236 | | release delete | yes | yes | yes | | 236 | | release delete | yes | yes | yes | |
| 237 | | release asset add | yes | no | n/a | | 237 | | release asset add | yes | no | n/a | |
| 238 | | release asset remove | yes | no | yes | | 238 | | release asset remove | yes | no | yes | |
| @@ -490,11 +490,10 @@ so =gitbay mr apply-suggestion= makes and signs it in a clone with the | |||
| 490 | user's own git signing configuration and pushes it. The web shows that | 490 | user's own git signing configuration and pushes it. The web shows that |
| 491 | command in place of the button. | 491 | command in place of the button. |
| 492 | 492 | ||
| 493 | Deleting or transferring a repository stays CLI-only on purpose, as | 493 | Deleting an organization and pruning merge request heads (=admin mr |
| 494 | does deleting an organization and pruning merge request heads (=admin | 494 | prune=) stay CLI-only for now. Deleting or transferring a repository is a |
| 495 | mr prune=): each removes or moves what clone URLs point at, and wants a | 495 | settings section on the web and asks for the repository's path to be |
| 496 | typed command rather than a button. Renaming is the exception: the iOS | 496 | typed first. |
| 497 | client offers it, and the web has no page yet. | ||
| 498 | 497 | ||
| 499 | =n/a= means a surface cannot usefully carry the capability at all — | 498 | =n/a= means a surface cannot usefully carry the capability at all — |
| 500 | see the archive note above. | 499 | see the archive note above. |
.gitbay/wiki/Users.org +4
| @@ -219,6 +219,10 @@ and =fork_of= when it is a | |||
| 219 | fork whose parent you can read, so a client draws a toggle rather than | 219 | fork whose parent you can read, so a client draws a toggle rather than |
| 220 | two blind buttons. Absent means none. | 220 | two blind buttons. Absent means none. |
| 221 | 221 | ||
| 222 | On the web, the repository's settings page carries access, webhooks, | ||
| 223 | rename, transfer and delete, and =/new= imports from a remote. Delete and | ||
| 224 | transfer ask for the repository's path to be typed. | ||
| 225 | |||
| 222 | Pushing is SSH-only. Public repositories are anonymously readable over | 226 | Pushing is SSH-only. Public repositories are anonymously readable over |
| 223 | HTTPS (and =git://= where enabled); private repositories exist only over | 227 | HTTPS (and =git://= where enabled); private repositories exist only over |
| 224 | SSH and answer "not found" to everyone without access. | 228 | SSH and answer "not found" to everyone without access. |
CHANGELOG.org +5
| @@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed. | |||
| 6 | 6 | ||
| 7 | * Unreleased | 7 | * Unreleased |
| 8 | 8 | ||
| 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). | ||
| 9 | - =web diff set unified|split= and a Diff layout control on the account | 14 | - =web diff set unified|split= and a Diff layout control on the account |
| 10 | page choose how the merge request, commit and compare pages draw a | 15 | page choose how the merge request, commit and compare pages draw a |
| 11 | diff; =?layout=split|unified= overrides it per request. The split | 16 | diff; =?layout=split|unified= overrides it per request. The split |
e2e/webhookweb_test.go added +52
| @@ -0,0 +1,52 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 15 | func 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) | |||
| 233 | owner = u.Username | 233 | owner = u.Username |
| 234 | } | 234 | } |
| 235 | name := r.FormValue("name") | 235 | 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 | } | ||
| 236 | argv := []string{"repo", "create", owner + "/" + name} | 254 | argv := []string{"repo", "create", owner + "/" + name} |
| 237 | if r.FormValue("visibility") == "private" { | 255 | if r.FormValue("visibility") == "private" { |
| 238 | argv = append(argv, "--private") | 256 | argv = append(argv, "--private") |
internal/httpd/settings.go +108 −4
| @@ -13,9 +13,8 @@ import ( | |||
| 13 | ) | 13 | ) |
| 14 | 14 | ||
| 15 | // Repository settings for repo admins. Every control dispatches the | 15 | // Repository settings for repo admins. Every control dispatches the |
| 16 | // command the CLI runs; the page only groups them. Destructive lifecycle | 16 | // command the CLI runs; the page only groups them. Delete and transfer |
| 17 | // — delete and transfer — stays on the CLI, where a typed confirmation | 17 | // ask for the repository's path to be typed first. |
| 18 | // is the norm. | ||
| 19 | 18 | ||
| 20 | type settingsPage struct { | 19 | type settingsPage struct { |
| 21 | repoPage | 20 | repoPage |
| @@ -24,12 +23,38 @@ type settingsPage struct { | |||
| 24 | DepsEnabled bool | 23 | DepsEnabled bool |
| 25 | Deps control.DepsOut | 24 | Deps control.DepsOut |
| 26 | Runners []store.RepoRunner | 25 | Runners []store.RepoRunner |
| 26 | Access []accessRow | ||
| 27 | Hooks []hookRow | ||
| 28 | Deliveries []deliveryRow | ||
| 27 | Notice string | 29 | Notice string |
| 28 | Saved bool | 30 | Saved bool |
| 29 | Reauth bool // Notice is the stale-session refusal: link to sign in | 31 | Reauth bool // Notice is the stale-session refusal: link to sign in |
| 30 | Submitted map[string]string | 32 | Submitted map[string]string |
| 31 | } | 33 | } |
| 32 | 34 | ||
| 35 | type accessRow struct { | ||
| 36 | User string `json:"user"` | ||
| 37 | Role string `json:"role"` | ||
| 38 | Source string `json:"source"` | ||
| 39 | } | ||
| 40 | |||
| 41 | type 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 | |||
| 48 | type 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 | |||
| 33 | func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { | 58 | func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| 34 | s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil) | 59 | s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil) |
| 35 | } | 60 | } |
| @@ -56,6 +81,12 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor | |||
| 56 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) | 81 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) |
| 57 | var runners []store.RepoRunner | 82 | var runners []store.RepoRunner |
| 58 | s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) | 83 | 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) | ||
| 59 | var subm map[string]string | 90 | var subm map[string]string |
| 60 | if submitted != nil { | 91 | if submitted != nil { |
| 61 | subm = map[string]string{ | 92 | subm = map[string]string{ |
| @@ -63,12 +94,19 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor | |||
| 63 | "website": submitted.Get("website"), | 94 | "website": submitted.Get("website"), |
| 64 | "topics": submitted.Get("topics"), | 95 | "topics": submitted.Get("topics"), |
| 65 | "key": submitted.Get("key"), | 96 | "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"), | ||
| 66 | } | 103 | } |
| 67 | } | 104 | } |
| 68 | s.render(w, "settings.html", settingsPage{ | 105 | s.render(w, "settings.html", settingsPage{ |
| 69 | repoPage: p, Topics: topics, Branches: branches, | 106 | repoPage: p, Topics: topics, Branches: branches, |
| 70 | DepsEnabled: deps.Enabled, Deps: deps, | 107 | DepsEnabled: deps.Enabled, Deps: deps, |
| 71 | Runners: runners, | 108 | Runners: runners, |
| 109 | Access: access, Hooks: hooks, Deliveries: deliveries, | ||
| 72 | Notice: notice, | 110 | Notice: notice, |
| 73 | Saved: strings.HasPrefix(notice, "Saved "), | 111 | Saved: strings.HasPrefix(notice, "Saved "), |
| 74 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | 112 | Reauth: s.reauthNotice(w, notice, r.URL.Path), |
| @@ -201,6 +239,68 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | |||
| 201 | return | 239 | return |
| 202 | case "runner-remove": | 240 | case "runner-remove": |
| 203 | argv = []string{"repo", "runner", "remove", repo, v("fingerprint")} | 241 | 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 | ||
| 204 | default: | 304 | default: |
| 205 | s.settingsRedirect(w, r, "unknown setting") | 305 | s.settingsRedirect(w, r, "unknown setting") |
| 206 | return | 306 | return |
| @@ -255,6 +355,10 @@ func fieldLabel(field string) string { | |||
| 255 | return "topics" | 355 | return "topics" |
| 256 | case "runner-add", "runner-remove": | 356 | case "runner-add", "runner-remove": |
| 257 | return "runner" | 357 | return "runner" |
| 358 | case "access-grant", "access-revoke": | ||
| 359 | return "access" | ||
| 360 | case "webhook-remove": | ||
| 361 | return "webhook" | ||
| 258 | default: | 362 | default: |
| 259 | return field | 363 | return field |
| 260 | } | 364 | } |
internal/httpd/settingsparity_test.go added +268
| @@ -0,0 +1,268 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 18 | type settingsEnv struct { | ||
| 19 | s *Server | ||
| 20 | st *store.Store | ||
| 21 | alice store.User | ||
| 22 | bob store.User | ||
| 23 | repo store.Repo | ||
| 24 | } | ||
| 25 | |||
| 26 | func 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 | |||
| 63 | func (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 | |||
| 73 | func (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 | |||
| 82 | func 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 | |||
| 107 | func 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 "nobody"") { | ||
| 111 | t.Fatalf("%d %s", rr.Code, rr.Body.String()) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | func 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 | |||
| 153 | func 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 | |||
| 161 | func 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 | |||
| 176 | func 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 | |||
| 194 | func 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 "nowhere"") { | ||
| 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. | ||
| 218 | func 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 | |||
| 250 | func 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 { | |||
| 787 | } | 787 | } |
| 788 | form.setform label { margin-top: 6px; } | 788 | form.setform label { margin-top: 6px; } |
| 789 | form.setform .hint { margin: 2px 0 0; } | 789 | form.setform .hint { margin: 2px 0 0; } |
| 790 | form.setform input[type="text"], form.setform select { width: 100%; } | 790 | form.setform input[type="text"], form.setform input[type="password"], form.setform select { width: 100%; } |
| 791 | /* a control narrower than its column is pushed to the column's end, | 791 | /* a control narrower than its column is pushed to the column's end, |
| 792 | which is where a full-width input's own right edge lands */ | 792 | which is where a full-width input's own right edge lands */ |
| 793 | form.setform .check { justify-content: flex-end; } | 793 | form.setform .check { justify-content: flex-end; } |
| @@ -796,7 +796,7 @@ form.setform .num input[type="number"] { width: auto; } | |||
| 796 | /* the third column takes the leftover width; the button keeps its own */ | 796 | /* the third column takes the leftover width; the button keeps its own */ |
| 797 | form.setform > button, form.setform > .btngroup { justify-self: start; } | 797 | form.setform > button, form.setform > .btngroup { justify-self: start; } |
| 798 | form.setform.stack { grid-template-columns: 1fr; } | 798 | form.setform.stack { grid-template-columns: 1fr; } |
| 799 | form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; } | 799 | form.setform.stack textarea, form.setform.stack select, form.setform.stack input[type="text"], form.setform.stack input[type="password"] { width: 100%; max-width: 48rem; } |
| 800 | ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; } | 800 | ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; } |
| 801 | ul.protlist li { | 801 | ul.protlist li { |
| 802 | display: flex; | 802 | display: flex; |
internal/web/templates/new.html +21
| @@ -21,6 +21,27 @@ | |||
| 21 | <p><button type="submit">Create repository</button></p> | 21 | <p><button type="submit">Create repository</button></p> |
| 22 | </form> | 22 | </form> |
| 23 | 23 | ||
| 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 | |||
| 24 | <h2 id="org">Organization</h2> | 45 | <h2 id="org">Organization</h2> |
| 25 | <p class="meta">An organization owns repositories, labels and milestones, and | 46 | <p class="meta">An organization owns repositories, labels and milestones, and |
| 26 | grants access through teams. You are its first admin.</p> | 47 | grants access through teams. You are its first admin.</p> |
internal/web/templates/settings.html +83 −2
| @@ -12,6 +12,7 @@ | |||
| 12 | <ul> | 12 | <ul> |
| 13 | <li><a href="#identity">Identity</a></li> | 13 | <li><a href="#identity">Identity</a></li> |
| 14 | <li><a href="#access">Access</a></li> | 14 | <li><a href="#access">Access</a></li> |
| 15 | <li><a href="#webhooks">Webhooks</a></li> | ||
| 15 | <li><a href="#gates">Merge gates</a></li> | 16 | <li><a href="#gates">Merge gates</a></li> |
| 16 | <li><a href="#branches">Protected branches</a></li> | 17 | <li><a href="#branches">Protected branches</a></li> |
| 17 | <li><a href="#tags">Protected tags</a></li> | 18 | <li><a href="#tags">Protected tags</a></li> |
| @@ -66,6 +67,68 @@ | |||
| 66 | <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div> | 67 | <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div> |
| 67 | <div><button type="submit" class="btn">Save</button></div> | 68 | <div><button type="submit" class="btn">Save</button></div> |
| 68 | </form> | 69 | </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}} | ||
| 69 | </section> | 132 | </section> |
| 70 | 133 | ||
| 71 | <section id="gates"><h2>Merge gates</h2> | 134 | <section id="gates"><h2>Merge gates</h2> |
| @@ -204,8 +267,26 @@ | |||
| 204 | <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> | 267 | <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> |
| 205 | <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div> | 268 | <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div> |
| 206 | </form> | 269 | </form> |
| 207 | <p class="meta">Deleting or transferring a repository is a CLI operation: | 270 | <form method="post" action="{{$base}}" class="setform"> |
| 208 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> | 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> | ||
| 209 | </section> | 290 | </section> |
| 210 | </div> | 291 | </div> |
| 211 | </div> | 292 | </div> |