Commit 7a58c237d3
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
.gitbay/wiki/Users.org +2 −1
| @@ -221,7 +221,8 @@ two blind buttons. Absent means none. | |||
| 221 | 221 | ||
| 222 | On the web, the repository's settings page carries access, webhooks, | 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 | 223 | rename, transfer and delete, and =/new= imports from a remote. Delete and |
| 224 | transfer ask for the repository's path to be typed. | 224 | transfer ask for the repository's path to be typed. Large imports belong |
| 225 | on the CLI, where progress is visible. | ||
| 225 | 226 | ||
| 226 | Pushing is SSH-only. Public repositories are anonymously readable over | 227 | Pushing is SSH-only. Public repositories are anonymously readable over |
| 227 | HTTPS (and =git://= where enabled); private repositories exist only over | 228 | HTTPS (and =git://= where enabled); private repositories exist only over |
internal/control/reauth_test.go +2
| @@ -122,8 +122,10 @@ func TestNeedsRecentSignInSet(t *testing.T) { | |||
| 122 | "org team grant", | 122 | "org team grant", |
| 123 | "pgp add", | 123 | "pgp add", |
| 124 | "repo access grant", | 124 | "repo access grant", |
| 125 | "repo delete", | ||
| 125 | "repo deploy-key add", | 126 | "repo deploy-key add", |
| 126 | "repo mirror add", | 127 | "repo mirror add", |
| 128 | "repo rename", | ||
| 127 | "repo runner add", | 129 | "repo runner add", |
| 128 | "repo secret set", | 130 | "repo secret set", |
| 129 | "repo settings visibility", | 131 | "repo settings visibility", |
internal/control/repo.go +8 −6
| @@ -56,13 +56,15 @@ func init() { | |||
| 56 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, | 56 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, |
| 57 | Run: runRepoTransfer}) | 57 | Run: runRepoTransfer}) |
| 58 | register(Command{Path: []string{"repo", "rename"}, | 58 | register(Command{Path: []string{"repo", "rename"}, |
| 59 | Summary: "rename a repository", | 59 | NeedsRecentSignIn: true, |
| 60 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", | 60 | Summary: "rename a repository", |
| 61 | Examples: []string{"repo rename krz/gitbay forge"}, | 61 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", |
| 62 | Run: runRepoRename}) | 62 | Examples: []string{"repo rename krz/gitbay forge"}, |
| 63 | Run: runRepoRename}) | ||
| 63 | register(Command{Path: []string{"repo", "delete"}, | 64 | register(Command{Path: []string{"repo", "delete"}, |
| 64 | Summary: "delete a repository", | 65 | NeedsRecentSignIn: true, |
| 65 | Usage: "repo delete <owner/name> --yes", | 66 | Summary: "delete a repository", |
| 67 | Usage: "repo delete <owner/name> --yes", | ||
| 66 | Flags: []Flag{ | 68 | Flags: []Flag{ |
| 67 | {"--yes", "", "confirm the permanent delete", ""}, | 69 | {"--yes", "", "confirm the permanent delete", ""}, |
| 68 | }, | 70 | }, |
internal/httpd/accounts.go +15 −8
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "html/template" | 5 | "html/template" |
| 6 | "log" | 6 | "log" |
| 7 | "net/http" | 7 | "net/http" |
| 8 | "net/url" | ||
| 8 | "path" | 9 | "path" |
| 9 | "slices" | 10 | "slices" |
| 10 | "strconv" | 11 | "strconv" |
| @@ -203,16 +204,22 @@ func (s *Server) adminOrgs(u store.User) []string { | |||
| 203 | return out | 204 | return out |
| 204 | } | 205 | } |
| 205 | 206 | ||
| 206 | func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { | 207 | func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string, submitted url.Values) { |
| 208 | // A refused import keeps what was typed, except the token. | ||
| 209 | subm := map[string]string{ | ||
| 210 | "owner": submitted.Get("owner"), "name": submitted.Get("name"), | ||
| 211 | "from": submitted.Get("from"), "visibility": submitted.Get("visibility"), | ||
| 212 | } | ||
| 207 | s.render(w, "new.html", struct { | 213 | s.render(w, "new.html", struct { |
| 208 | basePage | 214 | basePage |
| 209 | Orgs []string | 215 | Orgs []string |
| 210 | Error string | 216 | Error string |
| 211 | }{s.baseFor(u), s.adminOrgs(u), errMsg}) | 217 | Submitted map[string]string |
| 218 | }{s.baseFor(u), s.adminOrgs(u), errMsg, subm}) | ||
| 212 | } | 219 | } |
| 213 | 220 | ||
| 214 | func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { | 221 | func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| 215 | s.renderNewRepo(w, u, "") | 222 | s.renderNewRepo(w, u, "", nil) |
| 216 | } | 223 | } |
| 217 | 224 | ||
| 218 | // newSubmit creates a repository or an organization: /new carries both | 225 | // newSubmit creates a repository or an organization: /new carries both |
| @@ -222,7 +229,7 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) | |||
| 222 | if r.FormValue("field") == "org-create" { | 229 | if r.FormValue("field") == "org-create" { |
| 223 | name := strings.TrimSpace(r.FormValue("name")) | 230 | name := strings.TrimSpace(r.FormValue("name")) |
| 224 | if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok { | 231 | if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok { |
| 225 | s.renderNewRepo(w, u, msg) | 232 | s.renderNewRepo(w, u, msg, nil) |
| 226 | return | 233 | return |
| 227 | } | 234 | } |
| 228 | http.Redirect(w, r, "/"+name, http.StatusSeeOther) | 235 | http.Redirect(w, r, "/"+name, http.StatusSeeOther) |
| @@ -245,7 +252,7 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) | |||
| 245 | stdin = tok + "\n" | 252 | stdin = tok + "\n" |
| 246 | } | 253 | } |
| 247 | if msg, ok := s.runControlStdin(u, argv, stdin); !ok { | 254 | if msg, ok := s.runControlStdin(u, argv, stdin); !ok { |
| 248 | s.renderNewRepo(w, u, msg) | 255 | s.renderNewRepo(w, u, msg, r.Form) |
| 249 | return | 256 | return |
| 250 | } | 257 | } |
| 251 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) | 258 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) |
| @@ -256,7 +263,7 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) | |||
| 256 | argv = append(argv, "--private") | 263 | argv = append(argv, "--private") |
| 257 | } | 264 | } |
| 258 | if _, msg, ok := s.runControl(u, argv); !ok { | 265 | if _, msg, ok := s.runControl(u, argv); !ok { |
| 259 | s.renderNewRepo(w, u, msg) | 266 | s.renderNewRepo(w, u, msg, nil) |
| 260 | return | 267 | return |
| 261 | } | 268 | } |
| 262 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) | 269 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) |
internal/httpd/settings.go +3 −1
| @@ -288,7 +288,8 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | |||
| 288 | s.settingsFormWith(w, r, u, msg, r.Form) | 288 | s.settingsFormWith(w, r, u, msg, r.Form) |
| 289 | return | 289 | return |
| 290 | } | 290 | } |
| 291 | http.Redirect(w, r, "/"+v("new-owner")+"/"+r.PathValue("repo"), http.StatusSeeOther) | 291 | s.setFlash(w, "Saved the owner: transferred to "+v("new-owner")+".") |
| 292 | http.Redirect(w, r, "/"+v("new-owner")+"/"+r.PathValue("repo")+"/settings", http.StatusSeeOther) | ||
| 292 | return | 293 | return |
| 293 | case "delete": | 294 | case "delete": |
| 294 | if ok, msg := confirmed(r, repo); !ok { | 295 | if ok, msg := confirmed(r, repo); !ok { |
| @@ -299,6 +300,7 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | |||
| 299 | s.settingsFormWith(w, r, u, msg, r.Form) | 300 | s.settingsFormWith(w, r, u, msg, r.Form) |
| 300 | return | 301 | return |
| 301 | } | 302 | } |
| 303 | s.setFlash(w, "Deleted "+repo+".") | ||
| 302 | http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther) | 304 | http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther) |
| 303 | return | 305 | return |
| 304 | default: | 306 | default: |
internal/httpd/settingsparity_test.go +31 −1
| @@ -205,7 +205,7 @@ func TestSettingsTransfer(t *testing.T) { | |||
| 205 | t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String()) | 205 | t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String()) |
| 206 | } | 206 | } |
| 207 | rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}, "confirm": {"alice/app"}}) | 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" { | 208 | if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/krz/app/settings" { |
| 209 | t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location")) | 209 | t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location")) |
| 210 | } | 210 | } |
| 211 | if _, err := e.st.RepoByPath("krz/app"); err != nil { | 211 | if _, err := e.st.RepoByPath("krz/app"); err != nil { |
| @@ -266,3 +266,33 @@ func TestNewImportRefusalShown(t *testing.T) { | |||
| 266 | t.Fatal("import form missing") | 266 | t.Fatal("import form missing") |
| 267 | } | 267 | } |
| 268 | } | 268 | } |
| 269 | |||
| 270 | // A session older than the reauth window cannot delete or rename: the | ||
| 271 | // form comes back with the refusal and nothing changes. | ||
| 272 | func TestSettingsDeleteRenameNeedRecentSignIn(t *testing.T) { | ||
| 273 | e := newSettingsEnv(t) | ||
| 274 | stale := e.alice | ||
| 275 | stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute) | ||
| 276 | rr := e.post(stale, url.Values{"field": {"delete"}, "confirm": {"alice/app"}}) | ||
| 277 | if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Sign in again") { | ||
| 278 | t.Fatalf("delete: %d", rr.Code) | ||
| 279 | } | ||
| 280 | if _, err := e.st.RepoByPath("alice/app"); err != nil { | ||
| 281 | t.Fatal("a stale session deleted the repository") | ||
| 282 | } | ||
| 283 | rr = e.post(stale, url.Values{"field": {"rename"}, "name": {"tool"}}) | ||
| 284 | if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Sign in again") { | ||
| 285 | t.Fatalf("rename: %d", rr.Code) | ||
| 286 | } | ||
| 287 | if _, err := e.st.RepoByPath("alice/app"); err != nil { | ||
| 288 | t.Fatal("a stale session renamed the repository") | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | func TestSettingsDeleteTransferFlash(t *testing.T) { | ||
| 293 | e := newSettingsEnv(t) | ||
| 294 | rr := e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"alice/app"}}) | ||
| 295 | if c := strings.Join(rr.Header().Values("Set-Cookie"), ";"); !strings.Contains(c, "Deleted") { | ||
| 296 | t.Fatalf("no flash: %s", c) | ||
| 297 | } | ||
| 298 | } | ||
internal/web/templates/new.html +6 −6
| @@ -26,17 +26,17 @@ | |||
| 26 | <form method="post" action="/new" autocomplete="off"> | 26 | <form method="post" action="/new" autocomplete="off"> |
| 27 | <input type="hidden" name="field" value="import"> | 27 | <input type="hidden" name="field" value="import"> |
| 28 | <p><label>Owner <select name="owner"> | 28 | <p><label>Owner <select name="owner"> |
| 29 | <option value="{{.Viewer}}">{{.Viewer}}</option> | 29 | {{$o := index .Submitted "owner"}}<option value="{{.Viewer}}">{{.Viewer}}</option> |
| 30 | {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}} | 30 | {{range .Orgs}}<option value="{{.}}"{{if eq . $o}} selected{{end}}>{{.}}</option>{{end}} |
| 31 | </select></label> | 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> | 32 | <label>/ Name <input name="name" value="{{index .Submitted "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> | 33 | <p><label>From <input type="text" name="from" value="{{index .Submitted "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> | 34 | <p><label>Access token <input type="password" name="token" autocomplete="new-password"></label> <span class="hint">Optional.</span></p> |
| 35 | <fieldset class="segmented"> | 35 | <fieldset class="segmented"> |
| 36 | <legend>Visibility</legend> | 36 | <legend>Visibility</legend> |
| 37 | <div class="options"> | 37 | <div class="options"> |
| 38 | <label><input type="radio" name="visibility" value="public" checked><span>Public</span></label> | 38 | <label><input type="radio" name="visibility" value="public"{{if ne (index .Submitted "visibility") "private"}} checked{{end}}><span>Public</span></label> |
| 39 | <label><input type="radio" name="visibility" value="private"><span>Private</span></label> | 39 | <label><input type="radio" name="visibility" value="private"{{if eq (index .Submitted "visibility") "private"}} checked{{end}}><span>Private</span></label> |
| 40 | </div> | 40 | </div> |
| 41 | </fieldset> | 41 | </fieldset> |
| 42 | <p><button type="submit">Import repository</button></p> | 42 | <p><button type="submit">Import repository</button></p> |
internal/web/templates/owner.html +1
| @@ -9,6 +9,7 @@ | |||
| 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} | 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} |
| 10 | {{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}} | 10 | {{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}} |
| 11 | </section> | 11 | </section> |
| 12 | {{if and .Notice (ne .Tab "snippets") (ne .Tab "people")}}<p class="notice" role="status">{{.Notice}}</p>{{end}} | ||
| 12 | {{/* The profile is sections rather than one stack: a long About used to | 13 | {{/* The profile is sections rather than one stack: a long About used to |
| 13 | push the repositories off the bottom of the page (#242). About is | 14 | push the repositories off the bottom of the page (#242). About is |
| 14 | the bare /{owner}; the rest hang off /-/. A tab nobody may open is | 15 | the bare /{owner}; the rest hang off /-/. A tab nobody may open is |