Commit 7a58c237d3

7a58c237d3d3f14dded46caa5507834833aa5050

parent: 8a7afd05e0

Verified · cmc ci/build: success ci/test: success

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

web: reauth for repo delete and rename, import form keeps input, flash after transfer and delete

Ref #296

Layout: unified · split

.gitbay/wiki/Users.org +2 −1
@@ -221,7 +221,8 @@ two blind buttons. Absent means none.
221 221
222On the web, the repository's settings page carries access, webhooks, 222On the web, the repository's settings page carries access, webhooks,
223rename, transfer and delete, and =/new= imports from a remote. Delete and 223rename, transfer and delete, and =/new= imports from a remote. Delete and
224transfer ask for the repository's path to be typed. 224transfer ask for the repository's path to be typed. Large imports belong
225on the CLI, where progress is visible.
225 226
226Pushing is SSH-only. Public repositories are anonymously readable over 227Pushing is SSH-only. Public repositories are anonymously readable over
227HTTPS (and =git://= where enabled); private repositories exist only over 228HTTPS (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
206func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { 207func (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
214func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { 221func (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.
272func 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
292func 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