tenseleyflow/shithub / f7ddf11

Browse files

S31: team web surface — list, view, create, members, repo grants

Authored by espadonne
SHA
f7ddf118c8c264fa3fa0b78b7ee37506d00691d8
Parents
41454b8
Tree
438c45d

5 changed files

StatusFile+-
M internal/web/handlers/orgs/orgs.go 1 0
A internal/web/handlers/orgs/teams.go 307 0
M internal/web/templates/orgs/profile.html 1 0
A internal/web/templates/orgs/team_view.html 102 0
A internal/web/templates/orgs/teams_list.html 47 0
internal/web/handlers/orgs/orgs.gomodified
@@ -82,6 +82,7 @@ func (h *Handlers) MountOrgRoutes(r chi.Router) {
8282
 	r.Post("/{org}/people/invite", h.invite)
8383
 	r.Post("/{org}/people/{userID}/role", h.changeRole)
8484
 	r.Post("/{org}/people/{userID}/remove", h.removeMember)
85
+	h.MountTeams(r)
8586
 }
8687
 
8788
 // MountInvitations registers /invitations/{token}* — accept/decline.
internal/web/handlers/orgs/teams.goadded
@@ -0,0 +1,307 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strconv"
9
+	"strings"
10
+
11
+	"github.com/go-chi/chi/v5"
12
+	"github.com/jackc/pgx/v5"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/orgs"
15
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+// MountTeams registers the per-org team surface. Read paths are
20
+// public-but-filtered (secret teams hidden from non-members);
21
+// mutations are owner-gated inside each handler.
22
+func (h *Handlers) MountTeams(r chi.Router) {
23
+	r.Get("/{org}/teams", h.teamsList)
24
+	r.Post("/{org}/teams", h.teamCreate)
25
+	r.Get("/{org}/teams/{teamSlug}", h.teamView)
26
+	r.Post("/{org}/teams/{teamSlug}/members", h.teamMemberAddRemove)
27
+	r.Post("/{org}/teams/{teamSlug}/repos", h.teamRepoGrant)
28
+}
29
+
30
+// teamsList renders /{org}/teams. Filters secret teams out for
31
+// non-members + non-owners.
32
+func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) {
33
+	org, ok := h.orgFromSlug(w, r)
34
+	if !ok {
35
+		return
36
+	}
37
+	viewer := middleware.CurrentUserFromContext(r.Context())
38
+	all, err := orgsdb.New().ListTeamsForOrg(r.Context(), h.d.Pool, org.ID)
39
+	if err != nil {
40
+		h.d.Logger.ErrorContext(r.Context(), "teams: list", "error", err)
41
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
42
+		return
43
+	}
44
+	visible := h.filterSecretTeams(r, all, org.ID, viewer)
45
+	isOwner := false
46
+	if !viewer.IsAnonymous() {
47
+		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
48
+	}
49
+	_ = h.d.Render.RenderPage(w, r, "orgs/teams_list", map[string]any{
50
+		"Title":     org.Slug + " · teams",
51
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
52
+		"Org":       org,
53
+		"Teams":     visible,
54
+		"IsOwner":   isOwner,
55
+	})
56
+}
57
+
58
+// teamCreate handles POST /{org}/teams. Owner-only.
59
+func (h *Handlers) teamCreate(w http.ResponseWriter, r *http.Request) {
60
+	org, ok := h.orgFromSlug(w, r)
61
+	if !ok {
62
+		return
63
+	}
64
+	viewer := middleware.CurrentUserFromContext(r.Context())
65
+	if !h.requireOrgOwner(w, r, org.ID, viewer) {
66
+		return
67
+	}
68
+	if err := r.ParseForm(); err != nil {
69
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
70
+		return
71
+	}
72
+	parentID, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("parent_team_id")), 10, 64)
73
+	_, err := orgs.CreateTeam(r.Context(), h.deps(), orgs.CreateTeamParams{
74
+		OrgID:           org.ID,
75
+		Slug:            strings.TrimSpace(r.PostFormValue("slug")),
76
+		DisplayName:     strings.TrimSpace(r.PostFormValue("display_name")),
77
+		Description:     strings.TrimSpace(r.PostFormValue("description")),
78
+		ParentTeamID:    parentID,
79
+		Privacy:         strings.TrimSpace(r.PostFormValue("privacy")),
80
+		CreatedByUserID: viewer.ID,
81
+	})
82
+	if err != nil {
83
+		h.d.Logger.WarnContext(r.Context(), "teams: create",
84
+			"org", org.Slug, "error", err)
85
+	}
86
+	http.Redirect(w, r, "/"+string(org.Slug)+"/teams", http.StatusSeeOther)
87
+}
88
+
89
+// teamView renders /{org}/teams/{teamSlug}. Members + repo access.
90
+// Secret teams 404 for non-members + non-owners.
91
+func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) {
92
+	org, ok := h.orgFromSlug(w, r)
93
+	if !ok {
94
+		return
95
+	}
96
+	team, ok := h.teamFromSlug(w, r, org.ID)
97
+	if !ok {
98
+		return
99
+	}
100
+	viewer := middleware.CurrentUserFromContext(r.Context())
101
+	if !h.canSeeTeam(r, team, viewer) {
102
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
103
+		return
104
+	}
105
+	q := orgsdb.New()
106
+	members, _ := q.ListTeamMembers(r.Context(), h.d.Pool, team.ID)
107
+	repos, _ := q.ListTeamRepoAccess(r.Context(), h.d.Pool, team.ID)
108
+	isOwner := false
109
+	if !viewer.IsAnonymous() {
110
+		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
111
+	}
112
+	_ = h.d.Render.RenderPage(w, r, "orgs/team_view", map[string]any{
113
+		"Title":     string(org.Slug) + "/" + string(team.Slug),
114
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
115
+		"Org":       org,
116
+		"Team":      team,
117
+		"Members":   members,
118
+		"Repos":     repos,
119
+		"IsOwner":   isOwner,
120
+	})
121
+}
122
+
123
+// teamMemberAddRemove handles POST .../members. Form action=add|remove.
124
+// Both branches are owner-only; the orchestrator keeps idempotency.
125
+func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) {
126
+	org, ok := h.orgFromSlug(w, r)
127
+	if !ok {
128
+		return
129
+	}
130
+	team, ok := h.teamFromSlug(w, r, org.ID)
131
+	if !ok {
132
+		return
133
+	}
134
+	viewer := middleware.CurrentUserFromContext(r.Context())
135
+	if !h.requireOrgOwner(w, r, org.ID, viewer) {
136
+		return
137
+	}
138
+	if err := r.ParseForm(); err != nil {
139
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
140
+		return
141
+	}
142
+	username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username")))
143
+	action := r.PostFormValue("action")
144
+	if username == "" {
145
+		http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
146
+		return
147
+	}
148
+	uid, ok := h.userIDByUsername(r, username)
149
+	if !ok {
150
+		http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
151
+		return
152
+	}
153
+	switch action {
154
+	case "remove":
155
+		_ = orgs.RemoveTeamMember(r.Context(), h.deps(), team.ID, uid)
156
+	default:
157
+		role := r.PostFormValue("role")
158
+		_ = orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role)
159
+	}
160
+	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
161
+}
162
+
163
+// teamRepoGrant handles POST .../repos. Form expects repo_id + role,
164
+// or repo_id + action=remove. Owner-only.
165
+func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) {
166
+	org, ok := h.orgFromSlug(w, r)
167
+	if !ok {
168
+		return
169
+	}
170
+	team, ok := h.teamFromSlug(w, r, org.ID)
171
+	if !ok {
172
+		return
173
+	}
174
+	viewer := middleware.CurrentUserFromContext(r.Context())
175
+	if !h.requireOrgOwner(w, r, org.ID, viewer) {
176
+		return
177
+	}
178
+	if err := r.ParseForm(); err != nil {
179
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
180
+		return
181
+	}
182
+	repoID, err := strconv.ParseInt(r.PostFormValue("repo_id"), 10, 64)
183
+	if err != nil || repoID == 0 {
184
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
185
+		return
186
+	}
187
+	if r.PostFormValue("action") == "remove" {
188
+		_ = orgs.RevokeTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID)
189
+	} else {
190
+		_ = orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
191
+			r.PostFormValue("role"))
192
+	}
193
+	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
194
+}
195
+
196
+// ─── small helpers ─────────────────────────────────────────────────
197
+
198
+func (h *Handlers) teamFromSlug(w http.ResponseWriter, r *http.Request, orgID int64) (orgsdb.Team, bool) {
199
+	slug := chi.URLParam(r, "teamSlug")
200
+	row, err := orgsdb.New().GetTeamByOrgAndSlug(r.Context(), h.d.Pool, orgsdb.GetTeamByOrgAndSlugParams{
201
+		OrgID: orgID, Slug: slug,
202
+	})
203
+	if err != nil {
204
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
205
+		return orgsdb.Team{}, false
206
+	}
207
+	return row, true
208
+}
209
+
210
+func (h *Handlers) requireOrgOwner(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool {
211
+	if viewer.IsAnonymous() {
212
+		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
213
+		return false
214
+	}
215
+	owner, _ := orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID)
216
+	if !owner {
217
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
218
+		return false
219
+	}
220
+	return true
221
+}
222
+
223
+func (h *Handlers) userIDByUsername(r *http.Request, username string) (int64, bool) {
224
+	var id int64
225
+	err := h.d.Pool.QueryRow(r.Context(),
226
+		`SELECT id FROM users WHERE username = $1 AND deleted_at IS NULL`,
227
+		username,
228
+	).Scan(&id)
229
+	if err != nil {
230
+		return 0, false
231
+	}
232
+	return id, true
233
+}
234
+
235
+// canSeeTeam decides whether the viewer is allowed to see a team's
236
+// members + repos. Visible teams are public to all org members and
237
+// their basic info is public to everyone; secret teams are private
238
+// to (team members ∪ org owners). For simplicity the page-render
239
+// check requires ANY membership/owner; the list page does the same
240
+// filter when assembling the visible set.
241
+func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middleware.CurrentUser) bool {
242
+	if team.Privacy == orgsdb.TeamPrivacyVisible {
243
+		return true
244
+	}
245
+	if viewer.IsAnonymous() {
246
+		return false
247
+	}
248
+	// Org owner sees all.
249
+	if owner, _ := orgs.IsOwner(r.Context(), h.deps(), team.OrgID, viewer.ID); owner {
250
+		return true
251
+	}
252
+	// Team member?
253
+	_, err := orgsdb.New().ListTeamMembers(r.Context(), h.d.Pool, team.ID)
254
+	if err != nil {
255
+		return false
256
+	}
257
+	var member bool
258
+	_ = h.d.Pool.QueryRow(r.Context(),
259
+		`SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)`,
260
+		team.ID, viewer.ID,
261
+	).Scan(&member)
262
+	return member
263
+}
264
+
265
+// filterSecretTeams strips secret teams the viewer can't see.
266
+func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID int64, viewer middleware.CurrentUser) []orgsdb.Team {
267
+	if len(all) == 0 {
268
+		return all
269
+	}
270
+	out := make([]orgsdb.Team, 0, len(all))
271
+	isOwner := false
272
+	if !viewer.IsAnonymous() {
273
+		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID)
274
+	}
275
+	for _, t := range all {
276
+		if t.Privacy == orgsdb.TeamPrivacyVisible || isOwner {
277
+			out = append(out, t)
278
+			continue
279
+		}
280
+		// Secret + non-owner: only show when the viewer is a member.
281
+		if viewer.IsAnonymous() {
282
+			continue
283
+		}
284
+		var member bool
285
+		err := h.d.Pool.QueryRow(r.Context(),
286
+			`SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)`,
287
+			t.ID, viewer.ID,
288
+		).Scan(&member)
289
+		if err == nil && member {
290
+			out = append(out, t)
291
+		}
292
+	}
293
+	return out
294
+}
295
+
296
+func (h *Handlers) teamPath(org orgsdb.Org, team orgsdb.Team) string {
297
+	return "/" + string(org.Slug) + "/teams/" + string(team.Slug)
298
+}
299
+
300
+// ensure pgx is referenced when the rest of the file's imports
301
+// settle (avoids a "imported and not used" if a future refactor
302
+// drops the only inline pgx use).
303
+var _ = pgx.ErrNoRows
304
+
305
+// errTeamNotFound is reserved for the future; surfaced via
306
+// orgs.ErrTeamNotFound when needed.
307
+var _ = errors.New
internal/web/templates/orgs/profile.htmlmodified
@@ -6,6 +6,7 @@
66
     {{ if .Org.Description }}<p>{{ .Org.Description }}</p>{{ end }}
77
     <nav class="shithub-org-tabs">
88
       <a href="/{{ .Org.Slug }}/people">People ({{ .MemberCount }})</a>
9
+      <a href="/{{ .Org.Slug }}/teams">Teams</a>
910
       {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile">Settings</a>{{ end }}
1011
     </nav>
1112
   </header>
internal/web/templates/orgs/team_view.htmladded
@@ -0,0 +1,102 @@
1
+{{ define "page" -}}
2
+<section class="shithub-org-team">
3
+  <header class="shithub-org-profile-head">
4
+    <h1>{{ .Org.DisplayName }} / {{ .Team.Slug }}</h1>
5
+    <p class="shithub-meta">
6
+      <a href="/{{ .Org.Slug }}/teams">← teams</a>
7
+      {{ if eq (printf "%s" .Team.Privacy) "secret" }}<span class="shithub-pill shithub-pill-private">secret</span>{{ end }}
8
+    </p>
9
+    {{ if .Team.Description }}<p>{{ .Team.Description }}</p>{{ end }}
10
+  </header>
11
+
12
+  {{ if .IsOwner }}
13
+  <section class="shithub-org-invite">
14
+    <h2>Add member</h2>
15
+    <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/members">
16
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
17
+      <label><span>Username</span><input type="text" name="username" required></label>
18
+      <label><span>Role</span>
19
+        <select name="role">
20
+          <option value="member" selected>Member</option>
21
+          <option value="maintainer">Maintainer</option>
22
+        </select>
23
+      </label>
24
+      <button type="submit" class="shithub-button shithub-button-primary">Add</button>
25
+    </form>
26
+
27
+    <h2>Grant repo access</h2>
28
+    <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/repos">
29
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
30
+      <label><span>Repo ID</span><input type="number" name="repo_id" required min="1"></label>
31
+      <label><span>Role</span>
32
+        <select name="role">
33
+          <option value="read">Read</option>
34
+          <option value="triage">Triage</option>
35
+          <option value="write" selected>Write</option>
36
+          <option value="maintain">Maintain</option>
37
+          <option value="admin">Admin</option>
38
+        </select>
39
+      </label>
40
+      <button type="submit" class="shithub-button shithub-button-primary">Grant</button>
41
+    </form>
42
+  </section>
43
+  {{ end }}
44
+
45
+  <section class="shithub-org-members">
46
+    <h2>Members ({{ len .Members }})</h2>
47
+    <table class="shithub-table">
48
+      <thead><tr><th>User</th><th>Team role</th><th>Joined</th>{{ if .IsOwner }}<th></th>{{ end }}</tr></thead>
49
+      <tbody>
50
+        {{ range .Members }}
51
+        <tr>
52
+          <td><a href="/{{ .Username }}">@{{ .Username }}</a></td>
53
+          <td>{{ .Role }}</td>
54
+          <td>{{ relativeTime .AddedAt.Time }}</td>
55
+          {{ if $.IsOwner }}
56
+          <td>
57
+            <form method="POST" action="/{{ $.Org.Slug }}/teams/{{ $.Team.Slug }}/members" style="display:inline">
58
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
59
+              <input type="hidden" name="username" value="{{ .Username }}">
60
+              <input type="hidden" name="action" value="remove">
61
+              <button type="submit" class="shithub-button">Remove</button>
62
+            </form>
63
+          </td>
64
+          {{ end }}
65
+        </tr>
66
+        {{ end }}
67
+      </tbody>
68
+    </table>
69
+  </section>
70
+
71
+  <section class="shithub-org-members">
72
+    <h2>Repo access ({{ len .Repos }})</h2>
73
+    {{ if .Repos }}
74
+    <table class="shithub-table">
75
+      <thead><tr><th>Repo</th><th>Role</th>{{ if .IsOwner }}<th></th>{{ end }}</tr></thead>
76
+      <tbody>
77
+        {{ range .Repos }}
78
+        <tr>
79
+          <td><a href="/{{ $.Org.Slug }}/{{ .RepoName }}">{{ $.Org.Slug }}/{{ .RepoName }}</a>
80
+            {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
81
+          </td>
82
+          <td>{{ .Role }}</td>
83
+          {{ if $.IsOwner }}
84
+          <td>
85
+            <form method="POST" action="/{{ $.Org.Slug }}/teams/{{ $.Team.Slug }}/repos" style="display:inline">
86
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
87
+              <input type="hidden" name="repo_id" value="{{ .RepoID }}">
88
+              <input type="hidden" name="action" value="remove">
89
+              <button type="submit" class="shithub-button">Revoke</button>
90
+            </form>
91
+          </td>
92
+          {{ end }}
93
+        </tr>
94
+        {{ end }}
95
+      </tbody>
96
+    </table>
97
+    {{ else }}
98
+    <p class="shithub-empty">No repos granted to this team.</p>
99
+    {{ end }}
100
+  </section>
101
+</section>
102
+{{- end }}
internal/web/templates/orgs/teams_list.htmladded
@@ -0,0 +1,47 @@
1
+{{ define "page" -}}
2
+<section class="shithub-org-teams">
3
+  <header class="shithub-org-profile-head">
4
+    <h1>{{ .Org.DisplayName }} · Teams</h1>
5
+    <p class="shithub-meta">@{{ .Org.Slug }}</p>
6
+  </header>
7
+
8
+  {{ if .IsOwner }}
9
+  <section class="shithub-org-invite">
10
+    <h2>New team</h2>
11
+    <form method="POST" action="/{{ .Org.Slug }}/teams">
12
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
13
+      <label><span>Slug</span><input type="text" name="slug" required pattern="[a-z0-9](?:[a-z0-9._-]{0,48}[a-z0-9])?"></label>
14
+      <label><span>Display name</span><input type="text" name="display_name"></label>
15
+      <label><span>Description</span><input type="text" name="description"></label>
16
+      <label><span>Privacy</span>
17
+        <select name="privacy">
18
+          <option value="visible">Visible</option>
19
+          <option value="secret">Secret</option>
20
+        </select>
21
+      </label>
22
+      <button type="submit" class="shithub-button shithub-button-primary">Create team</button>
23
+    </form>
24
+  </section>
25
+  {{ end }}
26
+
27
+  <section class="shithub-org-members">
28
+    <h2>Teams ({{ len .Teams }})</h2>
29
+    {{ if .Teams }}
30
+    <ul class="shithub-repo-list">
31
+      {{ range .Teams }}
32
+      <li class="shithub-repo-list-row">
33
+        <h3 class="shithub-repo-list-name">
34
+          <a href="/{{ $.Org.Slug }}/teams/{{ .Slug }}">{{ .Slug }}</a>
35
+          {{ if eq (printf "%s" .Privacy) "secret" }}<span class="shithub-pill shithub-pill-private">secret</span>{{ end }}
36
+          {{ if .ParentTeamID.Valid }}<small class="shithub-meta">child team</small>{{ end }}
37
+        </h3>
38
+        {{ if .Description }}<p class="shithub-meta">{{ .Description }}</p>{{ end }}
39
+      </li>
40
+      {{ end }}
41
+    </ul>
42
+    {{ else }}
43
+    <p class="shithub-empty">No teams yet.</p>
44
+    {{ end }}
45
+  </section>
46
+</section>
47
+{{- end }}