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.
221221
222222On the web, the repository's settings page carries access, webhooks,
223223rename, 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.
225226
226227Pushing is SSH-only. Public repositories are anonymously readable over
227228HTTPS (and =git://= where enabled); private repositories exist only over
internal/control/reauth_test.go +2
@@ -122,8 +122,10 @@ func TestNeedsRecentSignInSet(t *testing.T) {
122122 "org team grant",
123123 "pgp add",
124124 "repo access grant",
125 "repo delete",
125126 "repo deploy-key add",
126127 "repo mirror add",
128 "repo rename",
127129 "repo runner add",
128130 "repo secret set",
129131 "repo settings visibility",
internal/control/repo.go +8 −6
@@ -56,13 +56,15 @@ func init() {
5656 Examples: []string{"repo transfer krz/gitbay krazywarez"},
5757 Run: runRepoTransfer})
5858 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})
6364 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",
6668 Flags: []Flag{
6769 {"--yes", "", "confirm the permanent delete", ""},
6870 },
internal/httpd/accounts.go +15 −8
@@ -5,6 +5,7 @@ import (
55 "html/template"
66 "log"
77 "net/http"
8 "net/url"
89 "path"
910 "slices"
1011 "strconv"
@@ -203,16 +204,22 @@ func (s *Server) adminOrgs(u store.User) []string {
203204 return out
204205}
205206
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 }
207213 s.render(w, "new.html", struct {
208214 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})
212219}
213220
214221func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
215 s.renderNewRepo(w, u, "")
222 s.renderNewRepo(w, u, "", nil)
216223}
217224
218225// 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)
222229 if r.FormValue("field") == "org-create" {
223230 name := strings.TrimSpace(r.FormValue("name"))
224231 if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
225 s.renderNewRepo(w, u, msg)
232 s.renderNewRepo(w, u, msg, nil)
226233 return
227234 }
228235 http.Redirect(w, r, "/"+name, http.StatusSeeOther)
@@ -245,7 +252,7 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
245252 stdin = tok + "\n"
246253 }
247254 if msg, ok := s.runControlStdin(u, argv, stdin); !ok {
248 s.renderNewRepo(w, u, msg)
255 s.renderNewRepo(w, u, msg, r.Form)
249256 return
250257 }
251258 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)
256263 argv = append(argv, "--private")
257264 }
258265 if _, msg, ok := s.runControl(u, argv); !ok {
259 s.renderNewRepo(w, u, msg)
266 s.renderNewRepo(w, u, msg, nil)
260267 return
261268 }
262269 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.
288288 s.settingsFormWith(w, r, u, msg, r.Form)
289289 return
290290 }
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)
292293 return
293294 case "delete":
294295 if ok, msg := confirmed(r, repo); !ok {
@@ -299,6 +300,7 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
299300 s.settingsFormWith(w, r, u, msg, r.Form)
300301 return
301302 }
303 s.setFlash(w, "Deleted "+repo+".")
302304 http.Redirect(w, r, "/"+r.PathValue("owner"), http.StatusSeeOther)
303305 return
304306 default:
internal/httpd/settingsparity_test.go +31 −1
@@ -205,7 +205,7 @@ func TestSettingsTransfer(t *testing.T) {
205205 t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String())
206206 }
207207 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" {
209209 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
210210 }
211211 if _, err := e.st.RepoByPath("krz/app"); err != nil {
@@ -266,3 +266,33 @@ func TestNewImportRefusalShown(t *testing.T) {
266266 t.Fatal("import form missing")
267267 }
268268}
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 @@
2626<form method="post" action="/new" autocomplete="off">
2727<input type="hidden" name="field" value="import">
2828<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}}
3131</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>
3434<p><label>Access token <input type="password" name="token" autocomplete="new-password"></label> <span class="hint">Optional.</span></p>
3535<fieldset class="segmented">
3636 <legend>Visibility</legend>
3737 <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>
4040 </div>
4141</fieldset>
4242<p><button type="submit">Import repository</button></p>
internal/web/templates/owner.html +1
@@ -9,6 +9,7 @@
99{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
1111</section>
12{{if and .Notice (ne .Tab "snippets") (ne .Tab "people")}}<p class="notice" role="status">{{.Notice}}</p>{{end}}
1213{{/* The profile is sections rather than one stack: a long About used to
1314 push the repositories off the bottom of the page (#242). About is
1415 the bare /{owner}; the rest hang off /-/. A tab nobody may open is