A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit fa1784cbc9

fa1784cbc9e727ebe89edd5db79f6555ebce9a3f

parent: 6b2b26db54

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T05:07:49Z

web: run an organization from its page

Membership and teams for org admins: add and remove members, create teams,
manage their members and repository grants. All dispatched through the org
commands, so the rules and audit entries have one implementation.

Lives on the owner page rather than /{owner}/settings, which would shadow
a repository legitimately named settings.

Ref #35
e2e/orgweb_test.go added +132
@@ -0,0 +1,132 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "strings"
8 "testing"
9)
10
11// TestOrgManagementWeb covers running an organization from the browser:
12// membership and teams, admin-gated, dispatched through the same commands
13// the CLI uses.
14func TestOrgManagementWeb(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
21 t.Fatalf("org create: %s", errOut)
22 }
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/widget"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26
27 alice := loginBrowser(t, inst, aliceKey)
28
29 // The management sections are admin-only: bob is not even a member.
30 bob := loginBrowser(t, inst, bobKey)
31 if _, body := browserGet(t, bob, inst.base()+"/acme"); strings.Contains(body, `value="member-add"`) {
32 t.Fatal("a non-member sees organization controls")
33 }
34 // And POSTing anyway is refused by the command, not by the template.
35 browserPost(t, bob, inst.base()+"/acme", url.Values{
36 "field": {"member-add"}, "user": {"bob"}, "role": {"admin"},
37 })
38 if members := orgMembers(t, inst, aliceKey); len(members) != 1 {
39 t.Fatalf("non-admin added themselves: %v", members)
40 }
41
42 status, body := browserGet(t, alice, inst.base()+"/acme")
43 if status != 200 || !strings.Contains(body, `value="member-add"`) {
44 t.Fatalf("admin sees no controls: %d", status)
45 }
46
47 // Add bob as a member through the form; confirm over SSH.
48 browserPost(t, alice, inst.base()+"/acme", url.Values{
49 "field": {"member-add"}, "user": {"bob"}, "role": {"member"},
50 })
51 if members := orgMembers(t, inst, aliceKey); len(members) != 2 {
52 t.Fatalf("member not added: %v", members)
53 }
54
55 // Create a team, put bob in it, and grant it write on the repo.
56 browserPost(t, alice, inst.base()+"/acme", url.Values{
57 "field": {"team-create"}, "team": {"builders"},
58 })
59 browserPost(t, alice, inst.base()+"/acme", url.Values{
60 "field": {"team-add"}, "team": {"builders"}, "user": {"bob"},
61 })
62 browserPost(t, alice, inst.base()+"/acme", url.Values{
63 "field": {"team-grant"}, "team": {"builders"},
64 "repo": {"acme/widget"}, "role": {"write"},
65 })
66 out, _, _ := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json")
67 if !strings.Contains(out, `"bob"`) || !strings.Contains(out, `"acme/widget"`) ||
68 !strings.Contains(out, `"write"`) {
69 t.Fatalf("team not configured: %s", out)
70 }
71 // The grant is real access, not just a row: bob can now push.
72 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/widget"); code != 0 {
73 t.Fatalf("team grant did not confer access: %s", errOut)
74 }
75
76 // The page shows what was built.
77 _, body = browserGet(t, alice, inst.base()+"/acme")
78 for _, want := range []string{"builders", "acme/widget", "1 member"} {
79 if !strings.Contains(body, want) {
80 t.Errorf("org page missing %q", want)
81 }
82 }
83
84 // Revoking and removing work the same way round.
85 browserPost(t, alice, inst.base()+"/acme", url.Values{
86 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"},
87 })
88 browserPost(t, alice, inst.base()+"/acme", url.Values{
89 "field": {"member-remove"}, "user": {"bob"},
90 })
91 if members := orgMembers(t, inst, aliceKey); len(members) != 1 {
92 t.Fatalf("member not removed: %v", members)
93 }
94}
95
96// loginBrowser mints a session over SSH and returns a browser holding it.
97func loginBrowser(t *testing.T, inst *instance, key string) *http.Client {
98 t.Helper()
99 out, errOut, code := inst.ssh(t, key, "", "web", "login", "--json")
100 if code != 0 {
101 t.Fatalf("web login: %s", errOut)
102 }
103 var env struct {
104 Data struct {
105 URL string `json:"url"`
106 } `json:"data"`
107 }
108 json.Unmarshal([]byte(out), &env)
109 c := newBrowser(t)
110 browserGet(t, c, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):])
111 return c
112}
113
114func orgMembers(t *testing.T, inst *instance, key string) []string {
115 t.Helper()
116 out, _, _ := inst.ssh(t, key, "", "org", "members", "list", "acme", "--json")
117 var env struct {
118 Data struct {
119 Members []struct {
120 User string `json:"user"`
121 } `json:"members"`
122 } `json:"data"`
123 }
124 if err := json.Unmarshal([]byte(out), &env); err != nil {
125 t.Fatalf("members JSON: %v\n%s", err, out)
126 }
127 var names []string
128 for _, m := range env.Data.Members {
129 names = append(names, m.User)
130 }
131 return names
132}
internal/httpd/orgweb.go added +89
@@ -0,0 +1,89 @@
1package httpd
2
3import (
4 "net/http"
5 "net/url"
6 "strings"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11// teamView is a team with the two things an admin needs to see about it:
12// who is in it and what it reaches.
13type teamView struct {
14 Name string
15 Members []string
16 Grants []store.TeamGrant
17}
18
19// orgAdminView gathers the management state for an organization page. It
20// returns ok=false for user pages and for viewers who do not administer the
21// org, so the page renders read-only for everyone else.
22func (s *Server) orgAdminView(viewer store.User, kind, name string) (teams []teamView, ok bool) {
23 if kind != "org" || viewer.ID == 0 {
24 return nil, false
25 }
26 org, err := s.st.OrgByName(name)
27 if err != nil {
28 return nil, false
29 }
30 if role, _ := s.st.OrgRole(org.ID, viewer.ID); role != "admin" {
31 return nil, false
32 }
33 list, _ := s.st.ListTeams(org.ID)
34 for _, t := range list {
35 members, _ := s.st.TeamMembers(t.ID)
36 grants, _ := s.st.TeamGrants(t.ID)
37 teams = append(teams, teamView{Name: t.Name, Members: members, Grants: grants})
38 }
39 return teams, true
40}
41
42// orgSubmit routes the organization forms on an owner page. Every branch
43// dispatches the matching control command, so membership rules and audit
44// entries stay in one implementation.
45func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
46 owner := r.PathValue("owner")
47 back := func(msg string) {
48 dest := "/" + owner
49 if msg != "" {
50 dest += "?e=" + url.QueryEscape(msg)
51 }
52 http.Redirect(w, r, dest, http.StatusSeeOther)
53 }
54 field := r.FormValue("field")
55 team := strings.TrimSpace(r.FormValue("team"))
56 user := strings.TrimSpace(r.FormValue("user"))
57
58 var argv []string
59 switch field {
60 case "member-add":
61 argv = []string{"org", "members", "add", owner, user}
62 if role := r.FormValue("role"); role == "admin" || role == "member" {
63 argv = append(argv, "--role", role)
64 }
65 case "member-remove":
66 argv = []string{"org", "members", "remove", owner, user}
67 case "team-create":
68 argv = []string{"org", "team", "create", owner, team}
69 case "team-delete":
70 argv = []string{"org", "team", "delete", owner, team}
71 case "team-add":
72 argv = append([]string{"org", "team", "add", owner, team}, strings.Fields(user)...)
73 case "team-remove":
74 argv = append([]string{"org", "team", "remove", owner, team}, strings.Fields(user)...)
75 case "team-grant":
76 argv = []string{"org", "team", "grant", owner, team,
77 strings.TrimSpace(r.FormValue("repo")), r.FormValue("role")}
78 case "team-revoke":
79 argv = []string{"org", "team", "revoke", owner, team, strings.TrimSpace(r.FormValue("repo"))}
80 default:
81 back("unknown form")
82 return
83 }
84 if _, msg, ok := s.runControl(u, argv); !ok {
85 back(msg)
86 return
87 }
88 back("")
89}
internal/httpd/routes.go +2
@@ -89,6 +89,8 @@ func (s *Server) Routes() []Route {
8989 Handler: s.checkOrigin(s.logout)},
9090 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
9191 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
92 Route{Method: "POST", Pattern: "/{owner}", Mutating: true,
93 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))},
9294 Route{Method: "POST", Pattern: "/settings", Mutating: true,
9395 Handler: s.checkOrigin(s.requireUser(s.accountSubmit))},
9496 )
internal/httpd/web.go +5 −1
@@ -392,6 +392,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
392392 }
393393 weeks, activityTotal := activityGrid(counts)
394394
395 teams, canAdmin := s.orgAdminView(viewer, kind, name)
395396 s.render(w, "owner.html", struct {
396397 basePage
397398 Owner string
@@ -402,8 +403,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
402403 Orgs []store.OrgMember
403404 Activity []activityWeek
404405 ActivityTotal int
406 Teams []teamView
407 CanAdmin bool
408 Notice string
405409 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
406 weeks, activityTotal})
410 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
407411 }
408412
409413 func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +3
@@ -797,6 +797,9 @@ pre.message {
797797 }
798798
799799 /* repo facts: the counts a visitor sizes a project up with */
800.teambody { padding: var(--sp-3); background: var(--bg); }
801.teambody .memberchip { margin-right: var(--sp-2); }
802
800803 /* account settings */
801804 table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; }
802805 table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; }
internal/web/templates/owner.html +75
@@ -20,4 +20,79 @@
2020 {{range .Repos}}{{template "reporow" .}}
2121 {{else}}<li class="empty">no visible repositories</li>{{end}}
2222 </ul>
23
24{{if .CanAdmin}}{{$org := .Owner}}
25<h2>people <span class="count">{{len .Members}}</span></h2>
26{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
27<div class="tablewrap"><table class="keys">
28<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr>
29{{range .Members}}<tr>
30 <td><a href="/{{.Username}}">{{.Username}}</a></td>
31 <td>{{.Role}}</td>
32 <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Username}}"><button type="submit" class="linklike">Remove</button></form></td>
33</tr>
34{{end}}</table></div>
35<details class="editbox">
36 <summary>Add or change a member</summary>
37 <form method="post" action="/{{$org}}" class="setform">
38 <input type="hidden" name="field" value="member-add">
39 <label for="member">Username</label>
40 <input type="text" id="member" name="user" required>
41 <label class="none" for="memberrole">Role</label>
42 <select id="memberrole" name="role">
43 <option value="member">member</option>
44 <option value="admin">admin</option>
45 </select>
46 <button type="submit">Apply</button>
47 </form>
48</details>
49
50<h2>teams <span class="count">{{len .Teams}}</span></h2>
51<p class="meta">Teams scope repository access inside the organization: members
52of a team get its role on every repository it is granted.</p>
53{{range .Teams}}<details class="difffold" open>
54 <summary><span class="fpath">{{.Name}}</span>
55 <span class="fstat">{{len .Members}} member{{if ne (len .Members) 1}}s{{end}} · {{len .Grants}} repo{{if ne (len .Grants) 1}}s{{end}}</span>
56 </summary>
57 <div class="teambody">
58 <p class="meta">members: {{range .Members}}<a class="memberchip" href="/{{.}}">{{.}}</a> {{else}}<span class="none">none yet</span>{{end}}</p>
59 <p class="meta">repositories: {{range .Grants}}<span class="memberchip"><a href="/{{.RepoPath}}">{{.RepoPath}}</a> <span class="role">{{.Role}}</span></span> {{else}}<span class="none">none yet</span>{{end}}</p>
60 <form method="post" action="/{{$org}}" class="setform">
61 <input type="hidden" name="team" value="{{.Name}}">
62 <label class="none" for="tm-{{.Name}}">Members</label>
63 <input type="text" id="tm-{{.Name}}" name="user" placeholder="usernames, space-separated">
64 <button type="submit" name="field" value="team-add">Add</button>
65 <button type="submit" name="field" value="team-remove">Remove</button>
66 </form>
67 <form method="post" action="/{{$org}}" class="setform">
68 <input type="hidden" name="team" value="{{.Name}}">
69 <label class="none" for="tr-{{.Name}}">Repository</label>
70 <input type="text" id="tr-{{.Name}}" name="repo" placeholder="owner/name">
71 <label class="none" for="trr-{{.Name}}">Role</label>
72 <select id="trr-{{.Name}}" name="role">
73 <option value="read">read</option>
74 <option value="write">write</option>
75 <option value="admin">admin</option>
76 </select>
77 <button type="submit" name="field" value="team-grant">Grant</button>
78 <button type="submit" name="field" value="team-revoke">Revoke</button>
79 </form>
80 <form method="post" action="/{{$org}}" class="setform">
81 <input type="hidden" name="field" value="team-delete">
82 <input type="hidden" name="team" value="{{.Name}}">
83 <button type="submit" class="linklike">Delete this team</button>
84 </form>
85 </div>
86</details>
87{{else}}<p class="none">No teams yet.</p>{{end}}
88<details class="editbox">
89 <summary>Create a team</summary>
90 <form method="post" action="/{{$org}}" class="setform">
91 <input type="hidden" name="field" value="team-create">
92 <label for="newteam">Name</label>
93 <input type="text" id="newteam" name="team" required>
94 <button type="submit">Create</button>
95 </form>
96</details>
97{{end}}
2398 {{end}}