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 | 222 | On the web, the repository's settings page carries access, webhooks, |
| 223 | 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 | 227 | Pushing is SSH-only. Public repositories are anonymously readable over |
| 227 | 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 | 122 | "org team grant", |
| 123 | 123 | "pgp add", |
| 124 | 124 | "repo access grant", |
| 125 | "repo delete", | |
| 125 | 126 | "repo deploy-key add", |
| 126 | 127 | "repo mirror add", |
| 128 | "repo rename", | |
| 127 | 129 | "repo runner add", |
| 128 | 130 | "repo secret set", |
| 129 | 131 | "repo settings visibility", |
internal/control/repo.go +8 −6
| @@ -56,13 +56,15 @@ func init() { | ||
| 56 | 56 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, |
| 57 | 57 | Run: runRepoTransfer}) |
| 58 | 58 | register(Command{Path: []string{"repo", "rename"}, |
| 59 | Summary: "rename a repository", | |
| 60 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", | |
| 61 | Examples: []string{"repo rename krz/gitbay forge"}, | |
| 62 | Run: runRepoRename}) | |
| 59 | NeedsRecentSignIn: true, | |
| 60 | Summary: "rename a repository", | |
| 61 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", | |
| 62 | Examples: []string{"repo rename krz/gitbay forge"}, | |
| 63 | Run: runRepoRename}) | |
| 63 | 64 | register(Command{Path: []string{"repo", "delete"}, |
| 64 | Summary: "delete a repository", | |
| 65 | Usage: "repo delete <owner/name> --yes", | |
| 65 | NeedsRecentSignIn: true, | |
| 66 | Summary: "delete a repository", | |
| 67 | Usage: "repo delete <owner/name> --yes", | |
| 66 | 68 | Flags: []Flag{ |
| 67 | 69 | {"--yes", "", "confirm the permanent delete", ""}, |
| 68 | 70 | }, |
internal/httpd/accounts.go +15 −8
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "html/template" |
| 6 | 6 | "log" |
| 7 | 7 | "net/http" |
| 8 | "net/url" | |
| 8 | 9 | "path" |
| 9 | 10 | "slices" |
| 10 | 11 | "strconv" |
| @@ -203,16 +204,22 @@ func (s *Server) adminOrgs(u store.User) []string { | ||
| 203 | 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 | 213 | s.render(w, "new.html", struct { |
| 208 | 214 | basePage |
| 209 | Orgs []string | |
| 210 | Error string | |
| 211 | }{s.baseFor(u), s.adminOrgs(u), errMsg}) | |
| 215 | Orgs []string | |
| 216 | Error string | |
| 217 | Submitted map[string]string | |
| 218 | }{s.baseFor(u), s.adminOrgs(u), errMsg, subm}) | |
| 212 | 219 | } |
| 213 | 220 | |
| 214 | 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 | 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 | 229 | if r.FormValue("field") == "org-create" { |
| 223 | 230 | name := strings.TrimSpace(r.FormValue("name")) |
| 224 | 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 | 233 | return |
| 227 | 234 | } |
| 228 | 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 | 252 | stdin = tok + "\n" |
| 246 | 253 | } |
| 247 | 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 | 256 | return |
| 250 | 257 | } |
| 251 | 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 | 263 | argv = append(argv, "--private") |
| 257 | 264 | } |
| 258 | 265 | if _, msg, ok := s.runControl(u, argv); !ok { |
| 259 | s.renderNewRepo(w, u, msg) | |
| 266 | s.renderNewRepo(w, u, msg, nil) | |
| 260 | 267 | return |
| 261 | 268 | } |
| 262 | 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 | 288 | s.settingsFormWith(w, r, u, msg, r.Form) |
| 289 | 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 | 293 | return |
| 293 | 294 | case "delete": |
| 294 | 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 | 300 | s.settingsFormWith(w, r, u, msg, r.Form) |
| 300 | 301 | return |
| 301 | 302 | } |
| 303 | s.setFlash(w, "Deleted "+repo+".") | |
| 302 | 304 | http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther) |
| 303 | 305 | return |
| 304 | 306 | default: |
internal/httpd/settingsparity_test.go +31 −1
| @@ -205,7 +205,7 @@ func TestSettingsTransfer(t *testing.T) { | ||
| 205 | 205 | t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String()) |
| 206 | 206 | } |
| 207 | 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 | 209 | t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location")) |
| 210 | 210 | } |
| 211 | 211 | if _, err := e.st.RepoByPath("krz/app"); err != nil { |
| @@ -266,3 +266,33 @@ func TestNewImportRefusalShown(t *testing.T) { | ||
| 266 | 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 | 26 | <form method="post" action="/new" autocomplete="off"> |
| 27 | 27 | <input type="hidden" name="field" value="import"> |
| 28 | 28 | <p><label>Owner <select name="owner"> |
| 29 | <option value="{{.Viewer}}">{{.Viewer}}</option> | |
| 30 | {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}} | |
| 29 | {{$o := index .Submitted "owner"}}<option value="{{.Viewer}}">{{.Viewer}}</option> | |
| 30 | {{range .Orgs}}<option value="{{.}}"{{if eq . $o}} selected{{end}}>{{.}}</option>{{end}} | |
| 31 | 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> | |
| 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" value="{{index .Submitted "from"}}" required placeholder="https://github.com/owner/repo.git" spellcheck="false" size="48"></label></p> | |
| 34 | 34 | <p><label>Access token <input type="password" name="token" autocomplete="new-password"></label> <span class="hint">Optional.</span></p> |
| 35 | 35 | <fieldset class="segmented"> |
| 36 | 36 | <legend>Visibility</legend> |
| 37 | 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> | |
| 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"{{if eq (index .Submitted "visibility") "private"}} checked{{end}}><span>Private</span></label> | |
| 40 | 40 | </div> |
| 41 | 41 | </fieldset> |
| 42 | 42 | <p><button type="submit">Import repository</button></p> |
internal/web/templates/owner.html +1
| @@ -9,6 +9,7 @@ | ||
| 9 | 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 | 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 | 11 | </section> |
| 12 | {{if and .Notice (ne .Tab "snippets") (ne .Tab "people")}}<p class="notice" role="status">{{.Notice}}</p>{{end}} | |
| 12 | 13 | {{/* The profile is sections rather than one stack: a long About used to |
| 13 | 14 | push the repositories off the bottom of the page (#242). About is |
| 14 | 15 | the bare /{owner}; the rest hang off /-/. A tab nobody may open is |