web: run an organization from its page !79

merged merged by cmc on 2026-08-26 05:08 UTC · krz/gitbay:org-web into main

6 files changed, +306 −1

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 {
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
409413func (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 */
801804table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; }
802805table.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}}