Commit 0f51fba689

0f51fba689b235cc0a456b938f3fa4f210f603f5

parent: 0f4f9b4c10

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

cmc <hello@cleberg.net> · 2026-09-06 15:05 UTC

web, wiki: create and rename an organization from the browser

Create is on your own profile page, rename on the organization's, both
dispatching the org commands. Delete stays CLI-only for the same reason
repository delete does, and the page says so.

Closes #167

Layout: unified · split

.gitbay/wiki/Parity.org +4 −2
@@ -269,7 +269,8 @@ missing UI rather than a rule.
269269| teams create, delete | yes | yes | yes |
270270| team members | yes | yes | yes |
271271| team repo grants | yes | yes | yes |
272| create, rename, delete | yes | no | no |
272| create, rename | yes | yes | no |
273| delete | yes | n/a | n/a |
273274
274275* Pagination
275276
@@ -283,7 +284,8 @@ with the same cursors; iOS pages with them too.
283284
284285Build secrets, mirror configuration and tokens, custom domain claims,
285286API token minting, deploy keys, account and instance administration. Deleting or transferring a repository is also
286CLI-only: both want a typed confirmation, not a button.
287CLI-only, as is deleting an organization: each wants a typed
288confirmation, not a button.
287289
288290These are the only rows where a =no= is intended. Everywhere else a
289291=no= is work outstanding, and =n/a= means a surface cannot usefully
e2e/orgweb_test.go +55
@@ -130,3 +130,58 @@ func orgMembers(t *testing.T, inst *instance, key string) []string {
130130 }
131131 return names
132132}
133
134// The organization lifecycle from a browser: create from your own page,
135// rename from the org's. Delete stays on the CLI, where a typed
136// confirmation is the norm (#167).
137func TestOrgLifecycleWeb(t *testing.T) {
138 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
139 aliceKey := inst.newKey(t, "alice")
140 bobKey := inst.newKey(t, "bob")
141 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
142 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
143 alice := loginBrowser(t, inst, aliceKey)
144 bob := loginBrowser(t, inst, bobKey)
145
146 // The create form is on your own page and nobody else's.
147 if _, body := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(body, `value="org-create"`) {
148 t.Fatalf("no create form on your own page:\n%s", body)
149 }
150 if _, body := browserGet(t, bob, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) {
151 t.Fatal("create form on someone else's page")
152 }
153
154 if status, _ := browserPost(t, alice, inst.base()+"/alice", url.Values{
155 "field": {"org-create"}, "name": {"acmeco"}}); status != 200 {
156 t.Fatal("org create failed")
157 }
158 if out, _, _ := inst.ssh(t, aliceKey, "", "org", "list", "--json"); !strings.Contains(out, "acmeco") {
159 t.Fatalf("org not created:\n%s", out)
160 }
161
162 // Rename is offered to its admin, and the org moves.
163 _, body := browserGet(t, alice, inst.base()+"/acmeco")
164 if !strings.Contains(body, `value="org-rename"`) {
165 t.Fatalf("no rename form for the org admin:\n%s", body)
166 }
167 if !strings.Contains(body, "gitbay org delete") || strings.Contains(body, `value="org-delete"`) {
168 t.Error("delete is not recorded as a CLI operation")
169 }
170 if status, _ := browserPost(t, alice, inst.base()+"/acmeco", url.Values{
171 "field": {"org-rename"}, "name": {"acmeltd"}}); status != 200 {
172 t.Fatal("org rename failed")
173 }
174 if _, _, code := inst.ssh(t, aliceKey, "", "org", "show", "acmeltd"); code != 0 {
175 t.Fatal("renamed org not found under its new name")
176 }
177 if status, _ := browserGet(t, alice, inst.base()+"/acmeco"); status != http.StatusNotFound {
178 t.Errorf("old org name still resolves: %d", status)
179 }
180
181 // A non-admin cannot rename it, form or no form.
182 browserPost(t, bob, inst.base()+"/acmeltd", url.Values{
183 "field": {"org-rename"}, "name": {"bobsltd"}})
184 if _, _, code := inst.ssh(t, aliceKey, "", "org", "show", "acmeltd"); code != 0 {
185 t.Fatal("a non-admin renamed the organization")
186 }
187}
internal/httpd/orgweb.go +17
@@ -51,6 +51,23 @@ func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User)
5151 team := strings.TrimSpace(r.FormValue("team"))
5252 user := strings.TrimSpace(r.FormValue("user"))
5353
54 // Create and rename land on a different page than the one they were
55 // posted from: a new org has no page yet, and a renamed one has moved.
56 switch field {
57 case "org-create", "org-rename":
58 name := strings.TrimSpace(r.FormValue("name"))
59 argv := []string{"org", "create", name}
60 if field == "org-rename" {
61 argv = []string{"org", "rename", owner, name}
62 }
63 if _, msg, ok := s.runControl(u, argv); !ok {
64 back(msg)
65 return
66 }
67 http.Redirect(w, r, "/"+name, http.StatusSeeOther)
68 return
69 }
70
5471 var argv []string
5572 switch field {
5673 case "member-add":
internal/httpd/web.go +4 −1
@@ -440,10 +440,13 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
440440 ActivityTotal int
441441 Teams []teamView
442442 CanAdmin bool
443 Self bool
443444 Notice string
444445 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
445446 d.Repos, d.Members, d.Orgs,
446 weeks, activityTotal, teams, canAdmin, s.takeFlash(w, r)})
447 weeks, activityTotal, teams, canAdmin,
448 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
449 s.takeFlash(w, r)})
447450}
448451
449452func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/web/templates/owner.html +29
@@ -23,6 +23,20 @@
2323{{else}}<li class="empty">no visible repositories</li>{{end}}
2424</ul>
2525
26{{if .Self}}
27<h2>organizations</h2>
28{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
29<details class="editbox">
30 <summary>New organization</summary>
31 <form method="post" action="/{{.Owner}}" class="setform">
32 <input type="hidden" name="field" value="org-create">
33 <label for="orgname">Name</label>
34 <input type="text" id="orgname" name="name" required>
35 <button type="submit">Create</button>
36 </form>
37</details>
38{{end}}
39
2640{{if .CanAdmin}}{{$org := .Owner}}
2741<h2>people <span class="count">{{len .Members}}</span></h2>
2842{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
@@ -96,5 +110,20 @@ of a team get its role on every repository it is granted.</p>
96110 <button type="submit">Create</button>
97111 </form>
98112</details>
113
114<h2>organization</h2>
115<details class="editbox">
116 <summary>Rename</summary>
117 <form method="post" action="/{{$org}}" class="setform">
118 <input type="hidden" name="field" value="org-rename">
119 <label for="orgrename">New name</label>
120 <input type="text" id="orgrename" name="name" value="{{$org}}" required>
121 <button type="submit">Rename</button>
122 </form>
123 <p class="meta">Every clone URL under this organization changes with the name.</p>
124</details>
125<p class="meta">Deleting an organization is a CLI operation, like deleting a
126repository: it wants a typed confirmation rather than a button.</p>
127<pre class="message">gitbay org delete {{$org}} --yes</pre>
99128{{end}}
100129{{end}}