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.
269| teams create, delete | yes | yes | yes | 269| teams create, delete | yes | yes | yes |
270| team members | yes | yes | yes | 270| team members | yes | yes | yes |
271| team repo grants | yes | yes | yes | 271| 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 |
273 274
274* Pagination 275* Pagination
275 276
@@ -283,7 +284,8 @@ with the same cursors; iOS pages with them too.
283 284
284Build secrets, mirror configuration and tokens, custom domain claims, 285Build secrets, mirror configuration and tokens, custom domain claims,
285API token minting, deploy keys, account and instance administration. Deleting or transferring a repository is also 286API 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.
287 289
288These are the only rows where a =no= is intended. Everywhere else a 290These are the only rows where a =no= is intended. Everywhere else a
289=no= is work outstanding, and =n/a= means a surface cannot usefully 291=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 {
130 } 130 }
131 return names 131 return names
132} 132}
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)
51 team := strings.TrimSpace(r.FormValue("team")) 51 team := strings.TrimSpace(r.FormValue("team"))
52 user := strings.TrimSpace(r.FormValue("user")) 52 user := strings.TrimSpace(r.FormValue("user"))
53 53
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
54 var argv []string 71 var argv []string
55 switch field { 72 switch field {
56 case "member-add": 73 case "member-add":
internal/httpd/web.go +4 −1
@@ -440,10 +440,13 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
440 ActivityTotal int 440 ActivityTotal int
441 Teams []teamView 441 Teams []teamView
442 CanAdmin bool 442 CanAdmin bool
443 Self bool
443 Notice string 444 Notice string
444 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), 445 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
445 d.Repos, d.Members, d.Orgs, 446 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)})
447} 450}
448 451
449func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 452func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/web/templates/owner.html +29
@@ -23,6 +23,20 @@
23{{else}}<li class="empty">no visible repositories</li>{{end}} 23{{else}}<li class="empty">no visible repositories</li>{{end}}
24</ul> 24</ul>
25 25
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
26{{if .CanAdmin}}{{$org := .Owner}} 40{{if .CanAdmin}}{{$org := .Owner}}
27<h2>people <span class="count">{{len .Members}}</span></h2> 41<h2>people <span class="count">{{len .Members}}</span></h2>
28{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 42{{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>
96 <button type="submit">Create</button> 110 <button type="submit">Create</button>
97 </form> 111 </form>
98</details> 112</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>
99{{end}} 128{{end}}
100{{end}} 129{{end}}