Commit fa1784cbc9

fa1784cbc9e727ebe89edd5db79f6555ebce9a3f

parent: 6b2b26db54

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26 05:07 UTC

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

Layout: unified · split

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 {
89 Handler: s.checkOrigin(s.logout)}, 89 Handler: s.checkOrigin(s.logout)},
90 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, 90 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
91 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, 91 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))},
92 Route{Method: "POST", Pattern: "/settings", Mutating: true, 94 Route{Method: "POST", Pattern: "/settings", Mutating: true,
93 Handler: s.checkOrigin(s.requireUser(s.accountSubmit))}, 95 Handler: s.checkOrigin(s.requireUser(s.accountSubmit))},
94 ) 96 )
internal/httpd/web.go +5 −1
@@ -392,6 +392,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
392 } 392 }
393 weeks, activityTotal := activityGrid(counts) 393 weeks, activityTotal := activityGrid(counts)
394 394
395 teams, canAdmin := s.orgAdminView(viewer, kind, name)
395 s.render(w, "owner.html", struct { 396 s.render(w, "owner.html", struct {
396 basePage 397 basePage
397 Owner string 398 Owner string
@@ -402,8 +403,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
402 Orgs []store.OrgMember 403 Orgs []store.OrgMember
403 Activity []activityWeek 404 Activity []activityWeek
404 ActivityTotal int 405 ActivityTotal int
406 Teams []teamView
407 CanAdmin bool
408 Notice string
405 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs, 409 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
406 weeks, activityTotal}) 410 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
407} 411}
408 412
409func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 413func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +3
@@ -797,6 +797,9 @@ pre.message {
797} 797}
798 798
799/* repo facts: the counts a visitor sizes a project up with */ 799/* 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
800/* account settings */ 803/* account settings */
801table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } 804table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; }
802table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; } 805table.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 @@
20{{range .Repos}}{{template "reporow" .}} 20{{range .Repos}}{{template "reporow" .}}
21{{else}}<li class="empty">no visible repositories</li>{{end}} 21{{else}}<li class="empty">no visible repositories</li>{{end}}
22</ul> 22</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}}
23{{end}} 98{{end}}