tenseleyflow/shithub / f926e68

Browse files

Align org team pages

Authored by espadonne
SHA
f926e6843a22b49df6a98a89d4c1f5ef6c51583f
Parents
047f0ac
Tree
bba0a9e

11 changed files

StatusFile+-
M internal/web/handlers/orgs/orgs.go 13 6
M internal/web/handlers/orgs/teams.go 292 33
A internal/web/handlers/orgs/teams_test.go 116 0
M internal/web/handlers/profile/org_profile.go 6 0
M internal/web/static/css/shithub.css 350 0
A internal/web/templates/orgs/_org_nav.html 13 0
M internal/web/templates/orgs/people.html 2 1
M internal/web/templates/orgs/profile.html 1 11
M internal/web/templates/orgs/team_view.html 160 82
M internal/web/templates/orgs/teams_list.html 92 40
M internal/web/templates/repo/settings_access.html 2 2
internal/web/handlers/orgs/orgs.gomodified
@@ -198,13 +198,20 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
198198
 			pending, _ = q.ListPendingInvitationsForOrg(r.Context(), h.d.Pool, org.ID)
199199
 		}
200200
 	}
201
+	var repoCount, teamCount int64
202
+	_ = h.d.Pool.QueryRow(r.Context(), `SELECT count(*) FROM repos WHERE owner_org_id = $1 AND deleted_at IS NULL`, org.ID).Scan(&repoCount)
203
+	_ = h.d.Pool.QueryRow(r.Context(), `SELECT count(*) FROM teams WHERE org_id = $1`, org.ID).Scan(&teamCount)
201204
 	_ = h.d.Render.RenderPage(w, r, "orgs/people", map[string]any{
202
-		"Title":     org.Slug + " · people",
203
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
204
-		"Org":       org,
205
-		"Members":   members,
206
-		"Pending":   pending,
207
-		"IsOwner":   isOwner,
205
+		"Title":        org.Slug + " · people",
206
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
207
+		"Org":          org,
208
+		"Members":      members,
209
+		"Pending":      pending,
210
+		"ActiveOrgTab": "people",
211
+		"RepoCount":    repoCount,
212
+		"MemberCount":  int64(len(members)),
213
+		"TeamCount":    teamCount,
214
+		"IsOwner":      isOwner,
208215
 	})
209216
 }
210217
 
internal/web/handlers/orgs/teams.gomodified
@@ -3,13 +3,14 @@
33
 package orgs
44
 
55
 import (
6
-	"errors"
6
+	"context"
77
 	"net/http"
8
+	"net/url"
89
 	"strconv"
910
 	"strings"
1011
 
1112
 	"github.com/go-chi/chi/v5"
12
-	"github.com/jackc/pgx/v5"
13
+	"github.com/jackc/pgx/v5/pgtype"
1314
 
1415
 	"github.com/tenseleyFlow/shithub/internal/orgs"
1516
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
@@ -27,14 +28,52 @@ func (h *Handlers) MountTeams(r chi.Router) {
2728
 	r.Post("/{org}/teams/{teamSlug}/repos", h.teamRepoGrant)
2829
 }
2930
 
30
-// teamsList renders /{org}/teams. Filters secret teams out for
31
-// non-members + non-owners.
31
+type orgNavCounts struct {
32
+	RepoCount   int64
33
+	MemberCount int64
34
+	TeamCount   int64
35
+}
36
+
37
+type teamAggregateCounts struct {
38
+	MemberCount int64
39
+	RepoCount   int64
40
+	ChildCount  int64
41
+}
42
+
43
+type teamListItem struct {
44
+	ID           int64
45
+	Slug         string
46
+	DisplayName  string
47
+	Description  string
48
+	Privacy      string
49
+	ParentSlug   string
50
+	Path         string
51
+	MemberCount  int64
52
+	RepoCount    int64
53
+	ChildCount   int64
54
+	IsSecret     bool
55
+	HasParent    bool
56
+	CreatedLabel string
57
+}
58
+
59
+type teamRepoCandidate struct {
60
+	ID         int64
61
+	Name       string
62
+	Visibility string
63
+}
64
+
65
+// teamsList renders /{org}/teams. GitHub keeps org teams member-only:
66
+// visible teams are visible to org members, while secret teams are
67
+// further limited to team members and org owners.
3268
 func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) {
3369
 	org, ok := h.orgFromSlug(w, r)
3470
 	if !ok {
3571
 		return
3672
 	}
3773
 	viewer := middleware.CurrentUserFromContext(r.Context())
74
+	if !h.canSeeOrgTeams(w, r, org.ID, viewer) {
75
+		return
76
+	}
3877
 	all, err := orgsdb.New().ListTeamsForOrg(r.Context(), h.d.Pool, org.ID)
3978
 	if err != nil {
4079
 		h.d.Logger.ErrorContext(r.Context(), "teams: list", "error", err)
@@ -42,16 +81,34 @@ func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) {
4281
 		return
4382
 	}
4483
 	visible := h.filterSecretTeams(r, all, org.ID, viewer)
84
+	counts := h.teamAggregateCounts(r.Context(), org.ID)
85
+	parentSlugs := teamParentSlugs(all)
86
+	items := h.teamListItems(org, visible, counts, parentSlugs)
87
+	visibleCount, secretCount := teamPrivacyCounts(items)
88
+	query := strings.TrimSpace(r.URL.Query().Get("q"))
89
+	privacy := strings.TrimSpace(r.URL.Query().Get("privacy"))
90
+	items = filterTeamListItems(items, query, privacy)
4591
 	isOwner := false
4692
 	if !viewer.IsAnonymous() {
4793
 		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
4894
 	}
95
+	navCounts := h.orgNavCounts(r.Context(), org.ID, int64(len(visible)))
4996
 	_ = 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,
97
+		"Title":          org.Slug + " · teams",
98
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
99
+		"Org":            org,
100
+		"AvatarURL":      "/avatars/" + url.PathEscape(string(org.Slug)),
101
+		"Teams":          items,
102
+		"TeamTotalCount": len(visible),
103
+		"VisibleCount":   visibleCount,
104
+		"SecretCount":    secretCount,
105
+		"Query":          query,
106
+		"PrivacyFilter":  privacy,
107
+		"ActiveOrgTab":   "teams",
108
+		"RepoCount":      navCounts.RepoCount,
109
+		"MemberCount":    navCounts.MemberCount,
110
+		"TeamCount":      navCounts.TeamCount,
111
+		"IsOwner":        isOwner,
55112
 	})
56113
 }
57114
 
@@ -98,6 +155,9 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) {
98155
 		return
99156
 	}
100157
 	viewer := middleware.CurrentUserFromContext(r.Context())
158
+	if !h.canSeeOrgTeams(w, r, org.ID, viewer) {
159
+		return
160
+	}
101161
 	if !h.canSeeTeam(r, team, viewer) {
102162
 		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
103163
 		return
@@ -105,18 +165,34 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) {
105165
 	q := orgsdb.New()
106166
 	members, _ := q.ListTeamMembers(r.Context(), h.d.Pool, team.ID)
107167
 	repos, _ := q.ListTeamRepoAccess(r.Context(), h.d.Pool, team.ID)
168
+	children, _ := q.ListChildTeams(r.Context(), h.d.Pool, pgtype.Int8{Int64: team.ID, Valid: true})
169
+	childItems := h.teamListItems(org, h.filterSecretTeams(r, children, org.ID, viewer),
170
+		h.teamAggregateCounts(r.Context(), org.ID), teamParentSlugs(children))
171
+	repoCandidates := h.teamRepoCandidates(r.Context(), org.ID, team.ID)
108172
 	isOwner := false
109173
 	if !viewer.IsAnonymous() {
110174
 		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
111175
 	}
176
+	navCounts := h.orgNavCounts(r.Context(), org.ID, -1)
112177
 	_ = 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,
178
+		"Title":           string(org.Slug) + "/" + string(team.Slug),
179
+		"CSRFToken":       middleware.CSRFTokenForRequest(r),
180
+		"Org":             org,
181
+		"AvatarURL":       "/avatars/" + url.PathEscape(string(org.Slug)),
182
+		"Team":            team,
183
+		"TeamDisplayName": teamDisplayName(team),
184
+		"TeamPath":        h.teamPath(org, team),
185
+		"TeamPrivacy":     string(team.Privacy),
186
+		"TeamIsSecret":    team.Privacy == orgsdb.TeamPrivacySecret,
187
+		"ChildTeams":      childItems,
188
+		"Members":         members,
189
+		"Repos":           repos,
190
+		"RepoCandidates":  repoCandidates,
191
+		"ActiveOrgTab":    "teams",
192
+		"RepoCount":       navCounts.RepoCount,
193
+		"MemberCount":     navCounts.MemberCount,
194
+		"TeamCount":       navCounts.TeamCount,
195
+		"IsOwner":         isOwner,
120196
 	})
121197
 }
122198
 
@@ -179,11 +255,15 @@ func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) {
179255
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
180256
 		return
181257
 	}
182
-	repoID, err := strconv.ParseInt(r.PostFormValue("repo_id"), 10, 64)
258
+	repoID, err := h.repoIDFromTeamForm(r, org.ID)
183259
 	if err != nil || repoID == 0 {
184260
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
185261
 		return
186262
 	}
263
+	if !h.repoBelongsToOrg(r.Context(), org.ID, repoID) {
264
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
265
+		return
266
+	}
187267
 	if r.PostFormValue("action") == "remove" {
188268
 		_ = orgs.RevokeTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID)
189269
 	} else {
@@ -207,6 +287,22 @@ func (h *Handlers) teamFromSlug(w http.ResponseWriter, r *http.Request, orgID in
207287
 	return row, true
208288
 }
209289
 
290
+func (h *Handlers) canSeeOrgTeams(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool {
291
+	if viewer.IsAnonymous() {
292
+		http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
293
+		return false
294
+	}
295
+	if viewer.IsSiteAdmin {
296
+		return true
297
+	}
298
+	isMember, _ := orgs.IsMember(r.Context(), h.deps(), orgID, viewer.ID)
299
+	if !isMember {
300
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
301
+		return false
302
+	}
303
+	return true
304
+}
305
+
210306
 func (h *Handlers) requireOrgOwner(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool {
211307
 	if viewer.IsAnonymous() {
212308
 		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
@@ -233,12 +329,10 @@ func (h *Handlers) userIDByUsername(r *http.Request, username string) (int64, bo
233329
 	return id, true
234330
 }
235331
 
236
-// canSeeTeam decides whether the viewer is allowed to see a team's
237
-// members + repos. Visible teams are public to all org members and
238
-// their basic info is public to everyone; secret teams are private
239
-// to (team members ∪ org owners). For simplicity the page-render
240
-// check requires ANY membership/owner; the list page does the same
241
-// filter when assembling the visible set.
332
+// canSeeTeam decides whether the viewer is allowed to see a team's members
333
+// and repositories. canSeeOrgTeams has already enforced org membership;
334
+// visible teams are readable to those members, while secret teams require
335
+// team membership or org ownership.
242336
 func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middleware.CurrentUser) bool {
243337
 	if team.Privacy == orgsdb.TeamPrivacyVisible {
244338
 		return true
@@ -264,7 +358,8 @@ func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middlewa
264358
 	return member
265359
 }
266360
 
267
-// filterSecretTeams strips secret teams the viewer can't see.
361
+// filterSecretTeams strips secret teams the viewer can't see after the
362
+// caller has already established org-team-page visibility.
268363
 func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID int64, viewer middleware.CurrentUser) []orgsdb.Team {
269364
 	if len(all) == 0 {
270365
 		return all
@@ -296,15 +391,179 @@ func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID i
296391
 	return out
297392
 }
298393
 
299
-func (h *Handlers) teamPath(org orgsdb.Org, team orgsdb.Team) string {
300
-	return "/" + string(org.Slug) + "/teams/" + string(team.Slug)
394
+func (h *Handlers) orgNavCounts(ctx context.Context, orgID int64, visibleTeamCount int64) orgNavCounts {
395
+	var counts orgNavCounts
396
+	_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM repos WHERE owner_org_id = $1 AND deleted_at IS NULL`, orgID).Scan(&counts.RepoCount)
397
+	_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID).Scan(&counts.MemberCount)
398
+	if visibleTeamCount >= 0 {
399
+		counts.TeamCount = visibleTeamCount
400
+	} else {
401
+		_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM teams WHERE org_id = $1`, orgID).Scan(&counts.TeamCount)
402
+	}
403
+	return counts
301404
 }
302405
 
303
-// ensure pgx is referenced when the rest of the file's imports
304
-// settle (avoids a "imported and not used" if a future refactor
305
-// drops the only inline pgx use).
306
-var _ = pgx.ErrNoRows
406
+func (h *Handlers) teamAggregateCounts(ctx context.Context, orgID int64) map[int64]teamAggregateCounts {
407
+	rows, err := h.d.Pool.Query(ctx, `
408
+		SELECT t.id,
409
+		       count(DISTINCT tm.user_id)::bigint AS member_count,
410
+		       count(DISTINCT tra.repo_id)::bigint AS repo_count,
411
+		       count(DISTINCT child.id)::bigint AS child_count
412
+		  FROM teams t
413
+		  LEFT JOIN team_members tm ON tm.team_id = t.id
414
+		  LEFT JOIN team_repo_access tra ON tra.team_id = t.id
415
+		  LEFT JOIN teams child ON child.parent_team_id = t.id
416
+		 WHERE t.org_id = $1
417
+		 GROUP BY t.id`, orgID)
418
+	if err != nil {
419
+		h.d.Logger.WarnContext(ctx, "teams: counts", "org_id", orgID, "error", err)
420
+		return nil
421
+	}
422
+	defer rows.Close()
423
+	out := map[int64]teamAggregateCounts{}
424
+	for rows.Next() {
425
+		var id int64
426
+		var c teamAggregateCounts
427
+		if err := rows.Scan(&id, &c.MemberCount, &c.RepoCount, &c.ChildCount); err == nil {
428
+			out[id] = c
429
+		}
430
+	}
431
+	return out
432
+}
433
+
434
+func (h *Handlers) teamListItems(org orgsdb.Org, teams []orgsdb.Team, counts map[int64]teamAggregateCounts, parentSlugs map[int64]string) []teamListItem {
435
+	out := make([]teamListItem, 0, len(teams))
436
+	for _, team := range teams {
437
+		c := counts[team.ID]
438
+		parentSlug := ""
439
+		if team.ParentTeamID.Valid {
440
+			parentSlug = parentSlugs[team.ParentTeamID.Int64]
441
+		}
442
+		out = append(out, teamListItem{
443
+			ID:           team.ID,
444
+			Slug:         string(team.Slug),
445
+			DisplayName:  teamDisplayName(team),
446
+			Description:  team.Description,
447
+			Privacy:      string(team.Privacy),
448
+			ParentSlug:   parentSlug,
449
+			Path:         h.teamPath(org, team),
450
+			MemberCount:  c.MemberCount,
451
+			RepoCount:    c.RepoCount,
452
+			ChildCount:   c.ChildCount,
453
+			IsSecret:     team.Privacy == orgsdb.TeamPrivacySecret,
454
+			HasParent:    team.ParentTeamID.Valid,
455
+			CreatedLabel: team.CreatedAt.Time.Format("Jan 2, 2006"),
456
+		})
457
+	}
458
+	return out
459
+}
307460
 
308
-// errTeamNotFound is reserved for the future; surfaced via
309
-// orgs.ErrTeamNotFound when needed.
310
-var _ = errors.New
461
+func teamParentSlugs(teams []orgsdb.Team) map[int64]string {
462
+	if len(teams) == 0 {
463
+		return nil
464
+	}
465
+	byID := make(map[int64]string, len(teams))
466
+	for _, team := range teams {
467
+		byID[team.ID] = string(team.Slug)
468
+	}
469
+	return byID
470
+}
471
+
472
+func teamDisplayName(team orgsdb.Team) string {
473
+	if strings.TrimSpace(team.DisplayName) != "" {
474
+		return team.DisplayName
475
+	}
476
+	return string(team.Slug)
477
+}
478
+
479
+func teamPrivacyCounts(items []teamListItem) (visibleCount, secretCount int) {
480
+	for _, item := range items {
481
+		if item.IsSecret {
482
+			secretCount++
483
+		} else {
484
+			visibleCount++
485
+		}
486
+	}
487
+	return visibleCount, secretCount
488
+}
489
+
490
+func filterTeamListItems(items []teamListItem, query, privacy string) []teamListItem {
491
+	query = strings.ToLower(strings.TrimSpace(query))
492
+	privacy = strings.ToLower(strings.TrimSpace(privacy))
493
+	if query == "" && privacy == "" {
494
+		return items
495
+	}
496
+	out := make([]teamListItem, 0, len(items))
497
+	for _, item := range items {
498
+		if privacy == "visible" && item.IsSecret {
499
+			continue
500
+		}
501
+		if privacy == "secret" && !item.IsSecret {
502
+			continue
503
+		}
504
+		if query != "" {
505
+			haystack := strings.ToLower(item.Slug + " " + item.DisplayName + " " + item.Description)
506
+			if !strings.Contains(haystack, query) {
507
+				continue
508
+			}
509
+		}
510
+		out = append(out, item)
511
+	}
512
+	return out
513
+}
514
+
515
+func (h *Handlers) teamRepoCandidates(ctx context.Context, orgID, teamID int64) []teamRepoCandidate {
516
+	rows, err := h.d.Pool.Query(ctx, `
517
+		SELECT r.id, r.name, r.visibility::text
518
+		  FROM repos r
519
+		  LEFT JOIN team_repo_access a
520
+		    ON a.repo_id = r.id AND a.team_id = $2
521
+		 WHERE r.owner_org_id = $1
522
+		   AND r.deleted_at IS NULL
523
+		   AND a.repo_id IS NULL
524
+		 ORDER BY lower(r.name)
525
+		 LIMIT 100`, orgID, teamID)
526
+	if err != nil {
527
+		h.d.Logger.WarnContext(ctx, "teams: repo candidates", "org_id", orgID, "team_id", teamID, "error", err)
528
+		return nil
529
+	}
530
+	defer rows.Close()
531
+	out := []teamRepoCandidate{}
532
+	for rows.Next() {
533
+		var item teamRepoCandidate
534
+		if err := rows.Scan(&item.ID, &item.Name, &item.Visibility); err == nil {
535
+			out = append(out, item)
536
+		}
537
+	}
538
+	return out
539
+}
540
+
541
+func (h *Handlers) repoIDFromTeamForm(r *http.Request, orgID int64) (int64, error) {
542
+	if raw := strings.TrimSpace(r.PostFormValue("repo_id")); raw != "" {
543
+		return strconv.ParseInt(raw, 10, 64)
544
+	}
545
+	repoName := strings.TrimSpace(r.PostFormValue("repo_name"))
546
+	if repoName == "" {
547
+		return 0, strconv.ErrSyntax
548
+	}
549
+	var id int64
550
+	err := h.d.Pool.QueryRow(
551
+		r.Context(),
552
+		`SELECT id FROM repos WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL`,
553
+		orgID, repoName,
554
+	).Scan(&id)
555
+	return id, err
556
+}
557
+
558
+func (h *Handlers) repoBelongsToOrg(ctx context.Context, orgID, repoID int64) bool {
559
+	var exists bool
560
+	err := h.d.Pool.QueryRow(ctx,
561
+		`SELECT EXISTS(SELECT 1 FROM repos WHERE id = $1 AND owner_org_id = $2 AND deleted_at IS NULL)`,
562
+		repoID, orgID,
563
+	).Scan(&exists)
564
+	return err == nil && exists
565
+}
566
+
567
+func (h *Handlers) teamPath(org orgsdb.Org, team orgsdb.Team) string {
568
+	return "/" + string(org.Slug) + "/teams/" + string(team.Slug)
569
+}
internal/web/handlers/orgs/teams_test.goadded
@@ -0,0 +1,116 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"net/http"
10
+	"net/http/httptest"
11
+	"strings"
12
+	"testing"
13
+	"testing/fstest"
14
+
15
+	"github.com/go-chi/chi/v5"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20
+	orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
21
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
22
+	"github.com/tenseleyFlow/shithub/internal/web/render"
23
+)
24
+
25
+func TestTeamsListRequiresOrgMemberAndFiltersSecretTeams(t *testing.T) {
26
+	t.Parallel()
27
+	ctx := context.Background()
28
+	pool := dbtest.NewTestDB(t)
29
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
30
+	memberID := insertOrgAvatarUser(t, pool, "member")
31
+	outsiderID := insertOrgAvatarUser(t, pool, "outsider")
32
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
33
+	if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'member')`, orgID, memberID); err != nil {
34
+		t.Fatalf("insert org member: %v", err)
35
+	}
36
+	visibleTeamID := insertTeamForTest(t, pool, orgID, "engineering", "Engineering", "visible")
37
+	insertTeamForTest(t, pool, orgID, "security", "Security", "secret")
38
+	if _, err := pool.Exec(ctx, `INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, 'member')`, visibleTeamID, memberID); err != nil {
39
+		t.Fatalf("insert team member: %v", err)
40
+	}
41
+
42
+	memberBody, memberStatus, _ := performTeamsListRequest(t, pool, middleware.CurrentUser{ID: memberID, Username: "member"}, "/acme/teams")
43
+	if memberStatus != http.StatusOK {
44
+		t.Fatalf("member status=%d body=%s", memberStatus, memberBody)
45
+	}
46
+	if !strings.Contains(memberBody, "TEAM=engineering:Engineering:1:0") {
47
+		t.Fatalf("expected visible team with counts, got %s", memberBody)
48
+	}
49
+	if strings.Contains(memberBody, "security") {
50
+		t.Fatalf("secret team leaked to non-team member: %s", memberBody)
51
+	}
52
+
53
+	outsiderBody, outsiderStatus, _ := performTeamsListRequest(t, pool, middleware.CurrentUser{ID: outsiderID, Username: "outsider"}, "/acme/teams")
54
+	if outsiderStatus != http.StatusNotFound {
55
+		t.Fatalf("outsider status=%d body=%s", outsiderStatus, outsiderBody)
56
+	}
57
+
58
+	_, anonymousStatus, anonymousLocation := performTeamsListRequest(t, pool, middleware.CurrentUser{}, "/acme/teams")
59
+	if anonymousStatus != http.StatusSeeOther {
60
+		t.Fatalf("anonymous status=%d", anonymousStatus)
61
+	}
62
+	if !strings.HasPrefix(anonymousLocation, "/login?next=") {
63
+		t.Fatalf("anonymous redirect=%q", anonymousLocation)
64
+	}
65
+}
66
+
67
+func performTeamsListRequest(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, target string) (string, int, string) {
68
+	t.Helper()
69
+	rr, err := render.New(fstest.MapFS{
70
+		"_layout.html":         {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
71
+		"orgs/_org_nav.html":   {Data: []byte(`{{ define "org-nav" }}NAV={{ .ActiveOrgTab }}:{{ .TeamCount }}{{ end }}`)},
72
+		"orgs/teams_list.html": {Data: []byte(`{{ define "page" }}{{ template "org-nav" . }} TOTAL={{ .TeamTotalCount }}{{ range .Teams }} TEAM={{ .Slug }}:{{ .DisplayName }}:{{ .MemberCount }}:{{ .RepoCount }}{{ end }}{{ end }}`)},
73
+		"orgs/team_view.html":  {Data: []byte(`{{ define "page" }}TEAM{{ end }}`)},
74
+		"orgs/people.html":     {Data: []byte(`{{ define "page" }}PEOPLE{{ end }}`)},
75
+		"errors/403.html":      {Data: []byte(`{{ define "page" }}403{{ end }}`)},
76
+		"errors/404.html":      {Data: []byte(`{{ define "page" }}404{{ end }}`)},
77
+		"errors/500.html":      {Data: []byte(`{{ define "page" }}500{{ end }}`)},
78
+	}, render.Options{})
79
+	if err != nil {
80
+		t.Fatalf("render.New: %v", err)
81
+	}
82
+	h, err := orgsh.New(orgsh.Deps{
83
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
84
+		Render: rr,
85
+		Pool:   pool,
86
+	})
87
+	if err != nil {
88
+		t.Fatalf("orgsh.New: %v", err)
89
+	}
90
+	r := chi.NewRouter()
91
+	r.Use(func(next http.Handler) http.Handler {
92
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
94
+		})
95
+	})
96
+	h.MountOrgRoutes(r)
97
+
98
+	req := httptest.NewRequest(http.MethodGet, target, nil)
99
+	rec := httptest.NewRecorder()
100
+	r.ServeHTTP(rec, req)
101
+	return rec.Body.String(), rec.Code, rec.Header().Get("Location")
102
+}
103
+
104
+func insertTeamForTest(t *testing.T, db orgsdb.DBTX, orgID int64, slug, displayName, privacy string) int64 {
105
+	t.Helper()
106
+	var id int64
107
+	if err := db.QueryRow(context.Background(),
108
+		`INSERT INTO teams (org_id, slug, display_name, privacy)
109
+		 VALUES ($1, $2, $3, $4)
110
+		 RETURNING id`,
111
+		orgID, slug, displayName, privacy,
112
+	).Scan(&id); err != nil {
113
+		t.Fatalf("insert team: %v", err)
114
+	}
115
+	return id
116
+}
internal/web/handlers/profile/org_profile.gomodified
@@ -99,6 +99,10 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID
9999
 	pinnedRepos, pinCandidates := h.orgPinData(ctx, org.ID, string(org.Slug), repos)
100100
 	people := h.orgProfilePeople(ctx, q, org.ID)
101101
 	memberCount := int64(len(people))
102
+	teamCount := int64(0)
103
+	if isMember || viewer.IsSiteAdmin {
104
+		_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM teams WHERE org_id = $1`, org.ID).Scan(&teamCount)
105
+	}
102106
 	viewAs := "Public"
103107
 	switch {
104108
 	case !viewer.IsAnonymous() && viewer.IsSiteAdmin:
@@ -123,11 +127,13 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID
123127
 		"PinCandidates":    pinCandidates,
124128
 		"PinsRemaining":    profilePinsRemaining(pinCandidates),
125129
 		"RepoCount":        int64(len(repos)),
130
+		"TeamCount":        teamCount,
126131
 		"MemberCount":      memberCount,
127132
 		"People":           limitOrgPeople(people, orgHomepagePeopleLimit),
128133
 		"TopLanguages":     orgTopLanguages(repos),
129134
 		"TopTopics":        orgTopTopics(repos),
130135
 		"ViewAs":           viewAs,
136
+		"ActiveOrgTab":     "overview",
131137
 		"IsOwner":          isOwner,
132138
 		"IsMember":         isMember,
133139
 		"CanCustomizePins": isOwner,
internal/web/static/css/shithub.cssmodified
@@ -2225,6 +2225,356 @@ code {
22252225
   color: var(--fg-default);
22262226
   font-size: 1rem;
22272227
 }
2228
+
2229
+.shithub-org-profile-head {
2230
+  max-width: 1280px;
2231
+  margin: 0 auto;
2232
+  padding: 1.5rem 1rem 0;
2233
+}
2234
+.shithub-org-teams-head {
2235
+  padding-bottom: 0.25rem;
2236
+}
2237
+.shithub-org-teams-title,
2238
+.shithub-org-team-title-row {
2239
+  display: flex;
2240
+  align-items: center;
2241
+  gap: 0.85rem;
2242
+}
2243
+.shithub-org-teams-title h1,
2244
+.shithub-org-team-title-row h1 {
2245
+  margin: 0;
2246
+  font-size: 1.5rem;
2247
+  line-height: 1.25;
2248
+}
2249
+.shithub-org-teams-avatar,
2250
+.shithub-org-team-avatar {
2251
+  flex: 0 0 auto;
2252
+  width: 48px;
2253
+  height: 48px;
2254
+  border: 1px solid var(--border-default);
2255
+  border-radius: 6px;
2256
+  background: var(--canvas-subtle);
2257
+}
2258
+.shithub-org-team-avatar {
2259
+  display: inline-flex;
2260
+  align-items: center;
2261
+  justify-content: center;
2262
+  color: var(--fg-muted);
2263
+}
2264
+.shithub-org-teams-layout,
2265
+.shithub-org-team-view-layout {
2266
+  max-width: 1280px;
2267
+  margin: 0 auto;
2268
+  display: grid;
2269
+  gap: 2rem;
2270
+  padding: 1.5rem 1rem 2.5rem;
2271
+}
2272
+.shithub-org-teams-layout {
2273
+  grid-template-columns: minmax(0, 1fr) minmax(260px, 0.34fr);
2274
+}
2275
+.shithub-org-team-view-layout {
2276
+  grid-template-columns: minmax(0, 1fr) 296px;
2277
+}
2278
+.shithub-org-teams-main,
2279
+.shithub-org-team-view-main {
2280
+  min-width: 0;
2281
+}
2282
+.shithub-org-team-toolbar {
2283
+  display: flex;
2284
+  justify-content: space-between;
2285
+  gap: 1rem;
2286
+  align-items: flex-start;
2287
+  margin-bottom: 1rem;
2288
+}
2289
+.shithub-org-team-toolbar h2,
2290
+.shithub-org-team-panel h2,
2291
+.shithub-org-team-manage-box h2 {
2292
+  margin: 0;
2293
+  font-size: 1rem;
2294
+  font-weight: 600;
2295
+}
2296
+.shithub-org-team-toolbar p,
2297
+.shithub-org-team-panel-head p,
2298
+.shithub-org-team-manage-box p {
2299
+  margin: 0.3rem 0 0;
2300
+  color: var(--fg-muted);
2301
+  font-size: 0.875rem;
2302
+}
2303
+.shithub-org-team-create {
2304
+  position: relative;
2305
+  flex: 0 0 auto;
2306
+}
2307
+.shithub-org-team-create summary {
2308
+  list-style: none;
2309
+}
2310
+.shithub-org-team-create summary::-webkit-details-marker {
2311
+  display: none;
2312
+}
2313
+.shithub-org-team-create[open] summary {
2314
+  border-bottom-left-radius: 0;
2315
+  border-bottom-right-radius: 0;
2316
+}
2317
+.shithub-org-team-create-form {
2318
+  position: absolute;
2319
+  right: 0;
2320
+  z-index: 30;
2321
+  width: min(360px, calc(100vw - 2rem));
2322
+  display: grid;
2323
+  gap: 0.75rem;
2324
+  padding: 1rem;
2325
+  border: 1px solid var(--border-default);
2326
+  border-radius: 6px 0 6px 6px;
2327
+  background: var(--canvas-default);
2328
+  box-shadow: 0 16px 48px rgba(1, 4, 9, 0.32);
2329
+}
2330
+.shithub-org-team-create-form label,
2331
+.shithub-org-team-manage-box label {
2332
+  display: grid;
2333
+  gap: 0.35rem;
2334
+  color: var(--fg-default);
2335
+  font-size: 0.875rem;
2336
+  font-weight: 600;
2337
+}
2338
+.shithub-org-team-create-form input,
2339
+.shithub-org-team-create-form select,
2340
+.shithub-org-team-manage-box input,
2341
+.shithub-org-team-manage-box select {
2342
+  width: 100%;
2343
+  min-height: 34px;
2344
+  padding: 0.35rem 0.6rem;
2345
+  border-radius: 6px;
2346
+}
2347
+.shithub-org-team-filters {
2348
+  display: grid;
2349
+  grid-template-columns: minmax(0, 1fr) auto;
2350
+  gap: 0.5rem;
2351
+  margin-bottom: 0.75rem;
2352
+}
2353
+.shithub-org-team-search {
2354
+  position: relative;
2355
+  display: block;
2356
+}
2357
+.shithub-org-team-search svg {
2358
+  position: absolute;
2359
+  top: 50%;
2360
+  left: 0.7rem;
2361
+  transform: translateY(-50%);
2362
+  color: var(--fg-muted);
2363
+  pointer-events: none;
2364
+}
2365
+.shithub-org-team-search input {
2366
+  width: 100%;
2367
+  min-height: 34px;
2368
+  padding: 0.35rem 0.7rem 0.35rem 2rem;
2369
+  border-radius: 6px;
2370
+}
2371
+.shithub-org-team-filter-tabs,
2372
+.shithub-org-team-tabs {
2373
+  display: flex;
2374
+  gap: 0.25rem;
2375
+  border-bottom: 1px solid var(--border-default);
2376
+  margin-bottom: 0;
2377
+  overflow-x: auto;
2378
+}
2379
+.shithub-org-team-filter-tabs a,
2380
+.shithub-org-team-tabs a {
2381
+  display: inline-flex;
2382
+  align-items: center;
2383
+  gap: 0.35rem;
2384
+  padding: 0.65rem 0.75rem;
2385
+  border-bottom: 2px solid transparent;
2386
+  color: var(--fg-default);
2387
+  font-size: 0.875rem;
2388
+  white-space: nowrap;
2389
+}
2390
+.shithub-org-team-filter-tabs a:hover,
2391
+.shithub-org-team-tabs a:hover {
2392
+  background: var(--canvas-subtle);
2393
+  border-radius: 6px 6px 0 0;
2394
+  text-decoration: none;
2395
+}
2396
+.shithub-org-team-filter-tabs a.is-selected,
2397
+.shithub-org-team-tabs a.is-selected {
2398
+  border-bottom-color: #fd8c73;
2399
+  font-weight: 600;
2400
+}
2401
+.shithub-org-team-filter-tabs span,
2402
+.shithub-org-team-tabs span {
2403
+  padding: 0.05rem 0.45rem;
2404
+  border-radius: 999px;
2405
+  background: var(--canvas-subtle);
2406
+  color: var(--fg-muted);
2407
+  font-size: 0.75rem;
2408
+  font-weight: 500;
2409
+}
2410
+.shithub-org-team-list,
2411
+.shithub-org-team-member-list,
2412
+.shithub-org-team-repo-list {
2413
+  list-style: none;
2414
+  padding: 0;
2415
+  margin: 0;
2416
+  border: 1px solid var(--border-default);
2417
+  border-top: 0;
2418
+  border-radius: 0 0 6px 6px;
2419
+  overflow: hidden;
2420
+  background: var(--canvas-default);
2421
+}
2422
+.shithub-org-team-list-compact {
2423
+  border-top: 1px solid var(--border-default);
2424
+  border-radius: 6px;
2425
+}
2426
+.shithub-org-team-row,
2427
+.shithub-org-team-member-row,
2428
+.shithub-org-team-repo-row {
2429
+  display: grid;
2430
+  gap: 1rem;
2431
+  align-items: center;
2432
+  padding: 1rem;
2433
+  border-top: 1px solid var(--border-default);
2434
+}
2435
+.shithub-org-team-row:first-child,
2436
+.shithub-org-team-member-row:first-child,
2437
+.shithub-org-team-repo-row:first-child {
2438
+  border-top: 0;
2439
+}
2440
+.shithub-org-team-row {
2441
+  grid-template-columns: 40px minmax(0, 1fr);
2442
+}
2443
+.shithub-org-team-row-icon,
2444
+.shithub-org-team-repo-icon {
2445
+  display: inline-flex;
2446
+  align-items: center;
2447
+  justify-content: center;
2448
+  width: 40px;
2449
+  height: 40px;
2450
+  border: 1px solid var(--border-default);
2451
+  border-radius: 6px;
2452
+  background: var(--canvas-subtle);
2453
+  color: var(--fg-muted);
2454
+}
2455
+.shithub-org-team-row-title {
2456
+  display: flex;
2457
+  align-items: center;
2458
+  gap: 0.45rem;
2459
+  flex-wrap: wrap;
2460
+  font-weight: 600;
2461
+}
2462
+.shithub-org-team-row-main p {
2463
+  margin: 0.35rem 0 0;
2464
+  color: var(--fg-muted);
2465
+  font-size: 0.875rem;
2466
+}
2467
+.shithub-org-team-row-meta {
2468
+  display: flex;
2469
+  flex-wrap: wrap;
2470
+  gap: 0.45rem 1rem;
2471
+  margin-top: 0.65rem;
2472
+  color: var(--fg-muted);
2473
+  font-size: 0.8rem;
2474
+}
2475
+.shithub-org-team-row-meta span,
2476
+.shithub-org-team-repo-main {
2477
+  display: inline-flex;
2478
+  align-items: center;
2479
+  gap: 0.35rem;
2480
+}
2481
+.shithub-org-team-view-head {
2482
+  padding-bottom: 0.25rem;
2483
+}
2484
+.shithub-org-team-breadcrumb {
2485
+  display: flex;
2486
+  gap: 0.4rem;
2487
+  align-items: center;
2488
+  margin-bottom: 0.75rem;
2489
+  color: var(--fg-muted);
2490
+  font-size: 0.875rem;
2491
+}
2492
+.shithub-org-team-title-row {
2493
+  flex-wrap: wrap;
2494
+}
2495
+.shithub-org-team-description {
2496
+  max-width: 760px;
2497
+  margin: 0.85rem 0 0;
2498
+}
2499
+.shithub-org-team-tabs {
2500
+  margin-bottom: 0;
2501
+}
2502
+.shithub-org-team-panel {
2503
+  margin-top: 1.5rem;
2504
+}
2505
+.shithub-org-team-panel-head {
2506
+  margin-bottom: 0.75rem;
2507
+}
2508
+.shithub-org-team-member-row {
2509
+  grid-template-columns: 40px minmax(0, 1fr) auto;
2510
+}
2511
+.shithub-org-team-member-row img {
2512
+  width: 40px;
2513
+  height: 40px;
2514
+  border-radius: 50%;
2515
+  border: 1px solid var(--border-muted);
2516
+}
2517
+.shithub-org-team-member-row p,
2518
+.shithub-org-team-repo-row p {
2519
+  margin: 0.25rem 0 0;
2520
+  color: var(--fg-muted);
2521
+  font-size: 0.8rem;
2522
+}
2523
+.shithub-org-team-repo-row {
2524
+  grid-template-columns: minmax(0, 1fr) auto;
2525
+}
2526
+.shithub-org-team-repo-main {
2527
+  min-width: 0;
2528
+}
2529
+.shithub-org-team-repo-main > div {
2530
+  min-width: 0;
2531
+}
2532
+.shithub-org-team-manage {
2533
+  min-width: 0;
2534
+}
2535
+.shithub-org-team-manage-box {
2536
+  padding: 1rem 0;
2537
+  border-top: 1px solid var(--border-default);
2538
+}
2539
+.shithub-org-team-manage-box:first-child {
2540
+  padding-top: 0;
2541
+  border-top: 0;
2542
+}
2543
+.shithub-org-team-manage-box form {
2544
+  display: grid;
2545
+  gap: 0.75rem;
2546
+  margin-top: 0.75rem;
2547
+}
2548
+
2549
+@media (max-width: 900px) {
2550
+  .shithub-org-teams-layout,
2551
+  .shithub-org-team-view-layout {
2552
+    grid-template-columns: 1fr;
2553
+  }
2554
+  .shithub-org-team-create-form {
2555
+    position: static;
2556
+    margin-top: 0.5rem;
2557
+    width: 100%;
2558
+    border-radius: 6px;
2559
+  }
2560
+}
2561
+
2562
+@media (max-width: 640px) {
2563
+  .shithub-org-team-toolbar,
2564
+  .shithub-org-team-filters {
2565
+    grid-template-columns: 1fr;
2566
+  }
2567
+  .shithub-org-team-toolbar {
2568
+    display: grid;
2569
+  }
2570
+  .shithub-org-team-member-row,
2571
+  .shithub-org-team-repo-row {
2572
+    grid-template-columns: 1fr;
2573
+  }
2574
+  .shithub-org-team-member-row img {
2575
+    display: none;
2576
+  }
2577
+}
22282578
 .shithub-modal-open {
22292579
   overflow: hidden;
22302580
 }
internal/web/templates/orgs/_org_nav.htmladded
@@ -0,0 +1,13 @@
1
+{{ define "org-nav" -}}
2
+<nav class="shithub-org-nav" aria-label="Organization">
3
+  <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item{{ if eq .ActiveOrgTab "overview" }} is-active{{ end }}">{{ octicon "home" }} Overview</a>
4
+  <a href="/{{ .Org.Slug }}#org-repositories" class="shithub-org-nav-item{{ if eq .ActiveOrgTab "repositories" }} is-active{{ end }}">{{ octicon "repo" }} Repositories <span class="shithub-tab-count">{{ .RepoCount }}</span></a>
5
+  <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "table" }} Projects</span>
6
+  <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "package" }} Packages</span>
7
+  <a href="/{{ .Org.Slug }}/teams" class="shithub-org-nav-item{{ if eq .ActiveOrgTab "teams" }} is-active{{ end }}">{{ octicon "people" }} Teams <span class="shithub-tab-count">{{ .TeamCount }}</span></a>
8
+  <a href="/{{ .Org.Slug }}/people" class="shithub-org-nav-item{{ if eq .ActiveOrgTab "people" }} is-active{{ end }}">{{ octicon "person" }} People <span class="shithub-tab-count">{{ .MemberCount }}</span></a>
9
+  <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "shield-check" }} Security and quality</span>
10
+  <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "pulse" }} Insights</span>
11
+  {{ if .IsOwner }}<a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-org-nav-item{{ if eq .ActiveOrgTab "settings" }} is-active{{ end }}">{{ octicon "gear" }} Settings</a>{{ end }}
12
+</nav>
13
+{{- end }}
internal/web/templates/orgs/people.htmlmodified
@@ -1,9 +1,10 @@
11
 {{ define "page" -}}
2
-<section class="shithub-org-people">
2
+<section class="shithub-org-profile shithub-org-people">
33
   <header class="shithub-org-profile-head">
44
     <h1>{{ .Org.DisplayName }} · People</h1>
55
     <p class="shithub-meta">@{{ .Org.Slug }}</p>
66
   </header>
7
+  {{ template "org-nav" . }}
78
 
89
   {{ if .IsOwner }}
910
   <section class="shithub-org-invite">
internal/web/templates/orgs/profile.htmlmodified
@@ -18,17 +18,7 @@
1818
     </div>
1919
   </header>
2020
 
21
-  <nav class="shithub-org-nav" aria-label="Organization">
22
-    <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item is-active">{{ octicon "home" }} Overview</a>
23
-    <a href="#org-repositories" class="shithub-org-nav-item">{{ octicon "repo" }} Repositories <span class="shithub-tab-count">{{ .RepoCount }}</span></a>
24
-    <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "table" }} Projects</span>
25
-    <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "package" }} Packages</span>
26
-    <a href="/{{ .Org.Slug }}/teams" class="shithub-org-nav-item">{{ octicon "people" }} Teams</a>
27
-    <a href="/{{ .Org.Slug }}/people" class="shithub-org-nav-item">{{ octicon "person" }} People <span class="shithub-tab-count">{{ .MemberCount }}</span></a>
28
-    <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "shield-check" }} Security and quality</span>
29
-    <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "pulse" }} Insights</span>
30
-    {{ if .IsOwner }}<a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-org-nav-item">{{ octicon "gear" }} Settings</a>{{ end }}
31
-  </nav>
21
+  {{ template "org-nav" . }}
3222
 
3323
   {{ if .Org.SuspendedAt.Valid }}
3424
   <p class="shithub-flash shithub-flash-error" role="alert">This organization is suspended. Pushes are blocked; reads continue.</p>
internal/web/templates/orgs/team_view.htmlmodified
@@ -1,102 +1,180 @@
11
 {{ 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 }}
2
+<section class="shithub-org-profile shithub-org-team">
3
+  <header class="shithub-org-profile-head shithub-org-team-view-head">
4
+    <div class="shithub-org-team-breadcrumb">
5
+      <a href="/{{ .Org.Slug }}">{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</a>
6
+      <span>/</span>
7
+      <a href="/{{ .Org.Slug }}/teams">Teams</a>
8
+    </div>
9
+    <div class="shithub-org-team-title-row">
10
+      <div class="shithub-org-team-avatar" aria-hidden="true">{{ octicon "people" }}</div>
11
+      <div>
12
+        <h1>{{ .TeamDisplayName }}</h1>
13
+        <p class="shithub-meta">@{{ .Org.Slug }}/{{ .Team.Slug }}</p>
14
+      </div>
15
+      <span class="shithub-pill{{ if .TeamIsSecret }} shithub-pill-private{{ end }}">{{ if .TeamIsSecret }}{{ octicon "lock" }} Secret{{ else }}{{ octicon "eye" }} Visible{{ end }}</span>
16
+    </div>
17
+    {{ if .Team.Description }}<p class="shithub-org-team-description">{{ .Team.Description }}</p>{{ else }}<p class="shithub-org-team-description shithub-muted">No description provided.</p>{{ end }}
1018
   </header>
1119
 
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>
20
+  {{ template "org-nav" . }}
2621
 
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 }}
22
+  <div class="shithub-org-team-view-layout">
23
+    <main class="shithub-org-team-view-main">
24
+      <nav class="shithub-org-team-tabs" aria-label="Team">
25
+        <a href="#members" class="is-selected">{{ octicon "person" }} Members <span>{{ len .Members }}</span></a>
26
+        <a href="#repositories">{{ octicon "repo" }} Repositories <span>{{ len .Repos }}</span></a>
27
+        <a href="#child-teams">{{ octicon "people" }} Child teams <span>{{ len .ChildTeams }}</span></a>
28
+      </nav>
4429
 
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">
30
+      <section id="members" class="shithub-org-team-panel">
31
+        <div class="shithub-org-team-panel-head">
32
+          <div>
33
+            <h2>Members</h2>
34
+            <p>{{ len .Members }} member{{ if ne (len .Members) 1 }}s{{ end }} belong directly to this team.</p>
35
+          </div>
36
+        </div>
37
+        {{ if .Members }}
38
+        <ol class="shithub-org-team-member-list">
39
+          {{ range .Members }}
40
+          <li class="shithub-org-team-member-row">
41
+            <img src="/avatars/{{ .Username }}" alt="" width="40" height="40">
42
+            <div>
43
+              <a href="/{{ .Username }}">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a>
44
+              <p>@{{ .Username }} · {{ .Role }} · joined {{ relativeTime .AddedAt.Time }}</p>
45
+            </div>
46
+            {{ if $.IsOwner }}
47
+            <form method="POST" action="/{{ $.Org.Slug }}/teams/{{ $.Team.Slug }}/members">
5848
               <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
5949
               <input type="hidden" name="username" value="{{ .Username }}">
6050
               <input type="hidden" name="action" value="remove">
61
-              <button type="submit" class="shithub-button">Remove</button>
51
+              <button type="submit" class="shithub-button shithub-button-danger">Remove</button>
6252
             </form>
63
-          </td>
53
+            {{ end }}
54
+          </li>
6455
           {{ end }}
65
-        </tr>
56
+        </ol>
57
+        {{ else }}
58
+        <div class="shithub-org-empty"><h3>No team members yet.</h3></div>
6659
         {{ end }}
67
-      </tbody>
68
-    </table>
69
-  </section>
60
+      </section>
7061
 
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">
62
+      <section id="repositories" class="shithub-org-team-panel">
63
+        <div class="shithub-org-team-panel-head">
64
+          <div>
65
+            <h2>Repositories</h2>
66
+            <p>Repository access granted directly to this team.</p>
67
+          </div>
68
+        </div>
69
+        {{ if .Repos }}
70
+        <ol class="shithub-org-team-repo-list">
71
+          {{ range .Repos }}
72
+          <li class="shithub-org-team-repo-row">
73
+            <div class="shithub-org-team-repo-main">
74
+              <span class="shithub-org-team-repo-icon">{{ octicon "repo" }}</span>
75
+              <div>
76
+                <a href="/{{ $.Org.Slug }}/{{ .RepoName }}">{{ $.Org.Slug }}/{{ .RepoName }}</a>
77
+                {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}
78
+                <p>{{ .Role }} access · granted {{ relativeTime .AddedAt.Time }}</p>
79
+              </div>
80
+            </div>
81
+            {{ if $.IsOwner }}
82
+            <form method="POST" action="/{{ $.Org.Slug }}/teams/{{ $.Team.Slug }}/repos">
8683
               <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
8784
               <input type="hidden" name="repo_id" value="{{ .RepoID }}">
8885
               <input type="hidden" name="action" value="remove">
89
-              <button type="submit" class="shithub-button">Revoke</button>
86
+              <button type="submit" class="shithub-button shithub-button-danger">Remove</button>
9087
             </form>
91
-          </td>
88
+            {{ end }}
89
+          </li>
9290
           {{ end }}
93
-        </tr>
91
+        </ol>
92
+        {{ else }}
93
+        <div class="shithub-org-empty"><h3>No repositories yet.</h3></div>
9494
         {{ end }}
95
-      </tbody>
96
-    </table>
97
-    {{ else }}
98
-    <p class="shithub-empty">No repos granted to this team.</p>
99
-    {{ end }}
100
-  </section>
95
+      </section>
96
+
97
+      <section id="child-teams" class="shithub-org-team-panel">
98
+        <div class="shithub-org-team-panel-head">
99
+          <div>
100
+            <h2>Child teams</h2>
101
+            <p>Child teams inherit repository permissions from this team.</p>
102
+          </div>
103
+        </div>
104
+        {{ if .ChildTeams }}
105
+        <ol class="shithub-org-team-list shithub-org-team-list-compact">
106
+          {{ range .ChildTeams }}
107
+          <li class="shithub-org-team-row">
108
+            <div class="shithub-org-team-row-icon" aria-hidden="true">{{ octicon "people" }}</div>
109
+            <div class="shithub-org-team-row-main">
110
+              <div class="shithub-org-team-row-title">
111
+                <a href="{{ .Path }}">{{ .DisplayName }}</a>
112
+                <span class="shithub-meta">@{{ $.Org.Slug }}/{{ .Slug }}</span>
113
+              </div>
114
+              <div class="shithub-org-team-row-meta">
115
+                <span>{{ octicon "person" }} {{ .MemberCount }} member{{ if ne .MemberCount 1 }}s{{ end }}</span>
116
+                <span>{{ octicon "repo" }} {{ .RepoCount }} repositor{{ if eq .RepoCount 1 }}y{{ else }}ies{{ end }}</span>
117
+              </div>
118
+            </div>
119
+          </li>
120
+          {{ end }}
121
+        </ol>
122
+        {{ else }}
123
+        <div class="shithub-org-empty"><h3>No child teams.</h3></div>
124
+        {{ end }}
125
+      </section>
126
+    </main>
127
+
128
+    <aside class="shithub-org-team-manage" aria-label="Team management">
129
+      {{ if .IsOwner }}
130
+      <section class="shithub-org-team-manage-box">
131
+        <h2>Add member</h2>
132
+        <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/members">
133
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
134
+          <label><span>Username</span><input type="text" name="username" required placeholder="@username"></label>
135
+          <label><span>Role</span>
136
+            <select name="role">
137
+              <option value="member" selected>Member</option>
138
+              <option value="maintainer">Maintainer</option>
139
+            </select>
140
+          </label>
141
+          <button type="submit" class="shithub-button shithub-button-primary">Add member</button>
142
+        </form>
143
+      </section>
144
+
145
+      <section class="shithub-org-team-manage-box">
146
+        <h2>Add repository</h2>
147
+        {{ if .RepoCandidates }}
148
+        <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/repos">
149
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
150
+          <label><span>Repository</span>
151
+            <select name="repo_id" required>
152
+              <option value="">Select repository</option>
153
+              {{ range .RepoCandidates }}<option value="{{ .ID }}">{{ $.Org.Slug }}/{{ .Name }} ({{ .Visibility }})</option>{{ end }}
154
+            </select>
155
+          </label>
156
+          <label><span>Role</span>
157
+            <select name="role">
158
+              <option value="read">Read</option>
159
+              <option value="triage">Triage</option>
160
+              <option value="write" selected>Write</option>
161
+              <option value="maintain">Maintain</option>
162
+              <option value="admin">Admin</option>
163
+            </select>
164
+          </label>
165
+          <button type="submit" class="shithub-button shithub-button-primary">Add repository</button>
166
+        </form>
167
+        {{ else }}
168
+        <p class="shithub-muted">Every organization repository is already linked to this team.</p>
169
+        {{ end }}
170
+      </section>
171
+      {{ end }}
172
+
173
+      <section class="shithub-org-team-manage-box">
174
+        <h2>Team visibility</h2>
175
+        <p>{{ if .TeamIsSecret }}Secret teams are visible to team members and organization owners.{{ else }}Visible teams can be found and mentioned by organization members.{{ end }}</p>
176
+      </section>
177
+    </aside>
178
+  </div>
101179
 </section>
102180
 {{- end }}
internal/web/templates/orgs/teams_list.htmlmodified
@@ -1,47 +1,99 @@
11
 {{ 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>
2
+<section class="shithub-org-profile shithub-org-teams">
3
+  <header class="shithub-org-profile-head shithub-org-teams-head">
4
+    <div class="shithub-org-teams-title">
5
+      <img class="shithub-org-teams-avatar" src="{{ .AvatarURL }}" alt="" width="48" height="48">
6
+      <div>
7
+        <h1>{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</h1>
8
+        <p class="shithub-meta">@{{ .Org.Slug }}</p>
9
+      </div>
10
+    </div>
611
   </header>
712
 
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 }}
13
+  {{ template "org-nav" . }}
2614
 
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>
15
+  <div class="shithub-org-teams-layout">
16
+    <main class="shithub-org-teams-main">
17
+      <div class="shithub-org-team-toolbar">
18
+        <div>
19
+          <h2>Teams</h2>
20
+          <p>Use teams to manage repository access and group organization members.</p>
21
+        </div>
22
+        {{ if .IsOwner }}
23
+        <details class="shithub-org-team-create">
24
+          <summary class="shithub-button shithub-button-primary">{{ octicon "people" }} New team</summary>
25
+          <form method="POST" action="/{{ .Org.Slug }}/teams" class="shithub-org-team-create-form">
26
+            <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
27
+            <label><span>Team name</span><input type="text" name="display_name" placeholder="Engineering" autocomplete="off"></label>
28
+            <label><span>Team slug</span><input type="text" name="slug" required pattern="[a-z0-9](?:[a-z0-9._-]{0,48}[a-z0-9])?" placeholder="engineering" autocomplete="off"></label>
29
+            <label><span>Description</span><input type="text" name="description" placeholder="What is this team responsible for?"></label>
30
+            <label><span>Privacy</span>
31
+              <select name="privacy">
32
+                <option value="visible">Visible</option>
33
+                <option value="secret">Secret</option>
34
+              </select>
35
+            </label>
36
+            <button type="submit" class="shithub-button shithub-button-primary">Create team</button>
37
+          </form>
38
+        </details>
39
+        {{ end }}
40
+      </div>
41
+
42
+      <form method="GET" action="/{{ .Org.Slug }}/teams" class="shithub-org-team-filters" role="search">
43
+        <label class="shithub-org-team-search">
44
+          {{ octicon "search" }}
45
+          <input type="search" name="q" value="{{ .Query }}" placeholder="Find a team..." aria-label="Find a team">
46
+        </label>
47
+        {{ if .PrivacyFilter }}<input type="hidden" name="privacy" value="{{ .PrivacyFilter }}">{{ end }}
48
+        <button type="submit" class="shithub-button">Search</button>
49
+      </form>
50
+
51
+      <nav class="shithub-org-team-filter-tabs" aria-label="Team filters">
52
+        <a href="/{{ .Org.Slug }}/teams{{ if .Query }}?q={{ urlquery .Query }}{{ end }}" class="{{ if not .PrivacyFilter }}is-selected{{ end }}">All <span>{{ .TeamTotalCount }}</span></a>
53
+        <a href="/{{ .Org.Slug }}/teams?privacy=visible{{ if .Query }}&amp;q={{ urlquery .Query }}{{ end }}" class="{{ if eq .PrivacyFilter "visible" }}is-selected{{ end }}">Visible <span>{{ .VisibleCount }}</span></a>
54
+        <a href="/{{ .Org.Slug }}/teams?privacy=secret{{ if .Query }}&amp;q={{ urlquery .Query }}{{ end }}" class="{{ if eq .PrivacyFilter "secret" }}is-selected{{ end }}">Secret <span>{{ .SecretCount }}</span></a>
55
+      </nav>
56
+
57
+      {{ if .Teams }}
58
+      <ol class="shithub-org-team-list">
59
+        {{ range .Teams }}
60
+        <li class="shithub-org-team-row">
61
+          <div class="shithub-org-team-row-icon" aria-hidden="true">{{ octicon "people" }}</div>
62
+          <div class="shithub-org-team-row-main">
63
+            <div class="shithub-org-team-row-title">
64
+              <a href="{{ .Path }}">{{ .DisplayName }}</a>
65
+              <span class="shithub-meta">@{{ $.Org.Slug }}/{{ .Slug }}</span>
66
+              {{ if .IsSecret }}<span class="shithub-pill shithub-pill-private">{{ octicon "lock" }} Secret</span>{{ else }}<span class="shithub-pill">{{ octicon "eye" }} Visible</span>{{ end }}
67
+            </div>
68
+            {{ if .Description }}<p>{{ .Description }}</p>{{ else }}<p class="shithub-muted">No description provided.</p>{{ end }}
69
+            <div class="shithub-org-team-row-meta">
70
+              <span>{{ octicon "person" }} {{ .MemberCount }} member{{ if ne .MemberCount 1 }}s{{ end }}</span>
71
+              <span>{{ octicon "repo" }} {{ .RepoCount }} repositor{{ if eq .RepoCount 1 }}y{{ else }}ies{{ end }}</span>
72
+              {{ if .ChildCount }}<span>{{ octicon "people" }} {{ .ChildCount }} child team{{ if ne .ChildCount 1 }}s{{ end }}</span>{{ end }}
73
+              {{ if .HasParent }}<span>{{ octicon "git-branch" }} Child of <a href="/{{ $.Org.Slug }}/teams/{{ .ParentSlug }}">{{ .ParentSlug }}</a></span>{{ end }}
74
+            </div>
75
+          </div>
76
+        </li>
77
+        {{ end }}
78
+      </ol>
79
+      {{ else }}
80
+      <div class="shithub-org-empty">
81
+        <h3>No teams found.</h3>
82
+        <p>Teams matching the current filters will appear here.</p>
83
+      </div>
4084
       {{ end }}
41
-    </ul>
42
-    {{ else }}
43
-    <p class="shithub-empty">No teams yet.</p>
44
-    {{ end }}
45
-  </section>
85
+    </main>
86
+
87
+    <aside class="shithub-org-sidebar" aria-label="Teams sidebar">
88
+      <section class="shithub-org-sidebox">
89
+        <h2>About teams</h2>
90
+        <p>Visible teams can be found by organization members. Secret teams are limited to team members and owners.</p>
91
+      </section>
92
+      <section class="shithub-org-sidebox">
93
+        <h2>Organization access</h2>
94
+        <p><strong>{{ .MemberCount }}</strong> member{{ if ne .MemberCount 1 }}s{{ end }} across <strong>{{ .TeamCount }}</strong> team{{ if ne .TeamCount 1 }}s{{ end }}.</p>
95
+      </section>
96
+    </aside>
97
+  </div>
4698
 </section>
4799
 {{- end }}
internal/web/templates/repo/settings_access.htmlmodified
@@ -54,7 +54,7 @@
5454
     {{ if .OwnerKindOrg }}
5555
     <section class="shithub-settings-section">
5656
       <h2>Team grants</h2>
57
-      <p class="shithub-hint">Teams from <a href="/orgs/{{ .Owner }}/teams">{{ .Owner }}</a> with access to this repo.</p>
57
+      <p class="shithub-hint">Teams from <a href="/{{ .Owner }}/teams">{{ .Owner }}</a> with access to this repo.</p>
5858
       {{ if .TeamGrants }}
5959
       <table class="shithub-branches-table">
6060
         <thead>
@@ -63,7 +63,7 @@
6363
         <tbody>
6464
           {{ range .TeamGrants }}
6565
           <tr>
66
-            <td><a href="/orgs/{{ $.Owner }}/teams/{{ .TeamSlug }}">{{ .TeamDisplayName }}</a> <span class="shithub-fg-muted">@{{ .TeamSlug }}</span></td>
66
+            <td><a href="/{{ $.Owner }}/teams/{{ .TeamSlug }}">{{ .TeamDisplayName }}</a> <span class="shithub-fg-muted">@{{ .TeamSlug }}</span></td>
6767
             <td>{{ .Role }}</td>
6868
             <td>{{ relativeTime .AddedAt.Time }}</td>
6969
             <td>