Align org team pages
- SHA
f926e6843a22b49df6a98a89d4c1f5ef6c51583f- Parents
-
047f0ac - Tree
bba0a9e
f926e68
f926e6843a22b49df6a98a89d4c1f5ef6c51583f047f0ac
bba0a9einternal/web/handlers/orgs/orgs.gomodified@@ -198,12 +198,19 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) { | ||
| 198 | 198 | pending, _ = q.ListPendingInvitationsForOrg(r.Context(), h.d.Pool, org.ID) |
| 199 | 199 | } |
| 200 | 200 | } |
| 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) | |
| 201 | 204 | _ = h.d.Render.RenderPage(w, r, "orgs/people", map[string]any{ |
| 202 | 205 | "Title": org.Slug + " · people", |
| 203 | 206 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 204 | 207 | "Org": org, |
| 205 | 208 | "Members": members, |
| 206 | 209 | "Pending": pending, |
| 210 | + "ActiveOrgTab": "people", | |
| 211 | + "RepoCount": repoCount, | |
| 212 | + "MemberCount": int64(len(members)), | |
| 213 | + "TeamCount": teamCount, | |
| 207 | 214 | "IsOwner": isOwner, |
| 208 | 215 | }) |
| 209 | 216 | } |
internal/web/handlers/orgs/teams.gomodified@@ -3,13 +3,14 @@ | ||
| 3 | 3 | package orgs |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | - "errors" | |
| 6 | + "context" | |
| 7 | 7 | "net/http" |
| 8 | + "net/url" | |
| 8 | 9 | "strconv" |
| 9 | 10 | "strings" |
| 10 | 11 | |
| 11 | 12 | "github.com/go-chi/chi/v5" |
| 12 | - "github.com/jackc/pgx/v5" | |
| 13 | + "github.com/jackc/pgx/v5/pgtype" | |
| 13 | 14 | |
| 14 | 15 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 15 | 16 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
@@ -27,14 +28,52 @@ func (h *Handlers) MountTeams(r chi.Router) { | ||
| 27 | 28 | r.Post("/{org}/teams/{teamSlug}/repos", h.teamRepoGrant) |
| 28 | 29 | } |
| 29 | 30 | |
| 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. | |
| 32 | 68 | func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) { |
| 33 | 69 | org, ok := h.orgFromSlug(w, r) |
| 34 | 70 | if !ok { |
| 35 | 71 | return |
| 36 | 72 | } |
| 37 | 73 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 74 | + if !h.canSeeOrgTeams(w, r, org.ID, viewer) { | |
| 75 | + return | |
| 76 | + } | |
| 38 | 77 | all, err := orgsdb.New().ListTeamsForOrg(r.Context(), h.d.Pool, org.ID) |
| 39 | 78 | if err != nil { |
| 40 | 79 | h.d.Logger.ErrorContext(r.Context(), "teams: list", "error", err) |
@@ -42,15 +81,33 @@ func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) { | ||
| 42 | 81 | return |
| 43 | 82 | } |
| 44 | 83 | 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) | |
| 45 | 91 | isOwner := false |
| 46 | 92 | if !viewer.IsAnonymous() { |
| 47 | 93 | isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID) |
| 48 | 94 | } |
| 95 | + navCounts := h.orgNavCounts(r.Context(), org.ID, int64(len(visible))) | |
| 49 | 96 | _ = h.d.Render.RenderPage(w, r, "orgs/teams_list", map[string]any{ |
| 50 | 97 | "Title": org.Slug + " · teams", |
| 51 | 98 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 52 | 99 | "Org": org, |
| 53 | - "Teams": visible, | |
| 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, | |
| 54 | 111 | "IsOwner": isOwner, |
| 55 | 112 | }) |
| 56 | 113 | } |
@@ -98,6 +155,9 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) { | ||
| 98 | 155 | return |
| 99 | 156 | } |
| 100 | 157 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 158 | + if !h.canSeeOrgTeams(w, r, org.ID, viewer) { | |
| 159 | + return | |
| 160 | + } | |
| 101 | 161 | if !h.canSeeTeam(r, team, viewer) { |
| 102 | 162 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 103 | 163 | return |
@@ -105,17 +165,33 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) { | ||
| 105 | 165 | q := orgsdb.New() |
| 106 | 166 | members, _ := q.ListTeamMembers(r.Context(), h.d.Pool, team.ID) |
| 107 | 167 | 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) | |
| 108 | 172 | isOwner := false |
| 109 | 173 | if !viewer.IsAnonymous() { |
| 110 | 174 | isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID) |
| 111 | 175 | } |
| 176 | + navCounts := h.orgNavCounts(r.Context(), org.ID, -1) | |
| 112 | 177 | _ = h.d.Render.RenderPage(w, r, "orgs/team_view", map[string]any{ |
| 113 | 178 | "Title": string(org.Slug) + "/" + string(team.Slug), |
| 114 | 179 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 115 | 180 | "Org": org, |
| 181 | + "AvatarURL": "/avatars/" + url.PathEscape(string(org.Slug)), | |
| 116 | 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, | |
| 117 | 188 | "Members": members, |
| 118 | 189 | "Repos": repos, |
| 190 | + "RepoCandidates": repoCandidates, | |
| 191 | + "ActiveOrgTab": "teams", | |
| 192 | + "RepoCount": navCounts.RepoCount, | |
| 193 | + "MemberCount": navCounts.MemberCount, | |
| 194 | + "TeamCount": navCounts.TeamCount, | |
| 119 | 195 | "IsOwner": isOwner, |
| 120 | 196 | }) |
| 121 | 197 | } |
@@ -179,11 +255,15 @@ func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) { | ||
| 179 | 255 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 180 | 256 | return |
| 181 | 257 | } |
| 182 | - repoID, err := strconv.ParseInt(r.PostFormValue("repo_id"), 10, 64) | |
| 258 | + repoID, err := h.repoIDFromTeamForm(r, org.ID) | |
| 183 | 259 | if err != nil || repoID == 0 { |
| 184 | 260 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 185 | 261 | return |
| 186 | 262 | } |
| 263 | + if !h.repoBelongsToOrg(r.Context(), org.ID, repoID) { | |
| 264 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 265 | + return | |
| 266 | + } | |
| 187 | 267 | if r.PostFormValue("action") == "remove" { |
| 188 | 268 | _ = orgs.RevokeTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID) |
| 189 | 269 | } else { |
@@ -207,6 +287,22 @@ func (h *Handlers) teamFromSlug(w http.ResponseWriter, r *http.Request, orgID in | ||
| 207 | 287 | return row, true |
| 208 | 288 | } |
| 209 | 289 | |
| 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 | + | |
| 210 | 306 | func (h *Handlers) requireOrgOwner(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool { |
| 211 | 307 | if viewer.IsAnonymous() { |
| 212 | 308 | 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 | ||
| 233 | 329 | return id, true |
| 234 | 330 | } |
| 235 | 331 | |
| 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. | |
| 242 | 336 | func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middleware.CurrentUser) bool { |
| 243 | 337 | if team.Privacy == orgsdb.TeamPrivacyVisible { |
| 244 | 338 | return true |
@@ -264,7 +358,8 @@ func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middlewa | ||
| 264 | 358 | return member |
| 265 | 359 | } |
| 266 | 360 | |
| 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. | |
| 268 | 363 | func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID int64, viewer middleware.CurrentUser) []orgsdb.Team { |
| 269 | 364 | if len(all) == 0 { |
| 270 | 365 | return all |
@@ -296,15 +391,179 @@ func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID i | ||
| 296 | 391 | return out |
| 297 | 392 | } |
| 298 | 393 | |
| 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 | |
| 301 | 404 | } |
| 302 | 405 | |
| 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 | +} | |
| 307 | 460 | |
| 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 | ||
| 99 | 99 | pinnedRepos, pinCandidates := h.orgPinData(ctx, org.ID, string(org.Slug), repos) |
| 100 | 100 | people := h.orgProfilePeople(ctx, q, org.ID) |
| 101 | 101 | 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 | + } | |
| 102 | 106 | viewAs := "Public" |
| 103 | 107 | switch { |
| 104 | 108 | case !viewer.IsAnonymous() && viewer.IsSiteAdmin: |
@@ -123,11 +127,13 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID | ||
| 123 | 127 | "PinCandidates": pinCandidates, |
| 124 | 128 | "PinsRemaining": profilePinsRemaining(pinCandidates), |
| 125 | 129 | "RepoCount": int64(len(repos)), |
| 130 | + "TeamCount": teamCount, | |
| 126 | 131 | "MemberCount": memberCount, |
| 127 | 132 | "People": limitOrgPeople(people, orgHomepagePeopleLimit), |
| 128 | 133 | "TopLanguages": orgTopLanguages(repos), |
| 129 | 134 | "TopTopics": orgTopTopics(repos), |
| 130 | 135 | "ViewAs": viewAs, |
| 136 | + "ActiveOrgTab": "overview", | |
| 131 | 137 | "IsOwner": isOwner, |
| 132 | 138 | "IsMember": isMember, |
| 133 | 139 | "CanCustomizePins": isOwner, |
internal/web/static/css/shithub.cssmodified@@ -2225,6 +2225,356 @@ code { | ||
| 2225 | 2225 | color: var(--fg-default); |
| 2226 | 2226 | font-size: 1rem; |
| 2227 | 2227 | } |
| 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 | +} | |
| 2228 | 2578 | .shithub-modal-open { |
| 2229 | 2579 | overflow: hidden; |
| 2230 | 2580 | } |
internal/web/templates/orgs/people.htmlmodified@@ -1,9 +1,10 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | -<section class="shithub-org-people"> | |
| 2 | +<section class="shithub-org-profile shithub-org-people"> | |
| 3 | 3 | <header class="shithub-org-profile-head"> |
| 4 | 4 | <h1>{{ .Org.DisplayName }} · People</h1> |
| 5 | 5 | <p class="shithub-meta">@{{ .Org.Slug }}</p> |
| 6 | 6 | </header> |
| 7 | + {{ template "org-nav" . }} | |
| 7 | 8 | |
| 8 | 9 | {{ if .IsOwner }} |
| 9 | 10 | <section class="shithub-org-invite"> |
internal/web/templates/orgs/profile.htmlmodified@@ -18,17 +18,7 @@ | ||
| 18 | 18 | </div> |
| 19 | 19 | </header> |
| 20 | 20 | |
| 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" . }} | |
| 32 | 22 | |
| 33 | 23 | {{ if .Org.SuspendedAt.Valid }} |
| 34 | 24 | <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,33 +1,158 @@ | ||
| 1 | 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 }} | |
| 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 }} | |
| 10 | 18 | </header> |
| 11 | 19 | |
| 20 | + {{ template "org-nav" . }} | |
| 21 | + | |
| 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> | |
| 29 | + | |
| 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"> | |
| 48 | + <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> | |
| 49 | + <input type="hidden" name="username" value="{{ .Username }}"> | |
| 50 | + <input type="hidden" name="action" value="remove"> | |
| 51 | + <button type="submit" class="shithub-button shithub-button-danger">Remove</button> | |
| 52 | + </form> | |
| 53 | + {{ end }} | |
| 54 | + </li> | |
| 55 | + {{ end }} | |
| 56 | + </ol> | |
| 57 | + {{ else }} | |
| 58 | + <div class="shithub-org-empty"><h3>No team members yet.</h3></div> | |
| 59 | + {{ end }} | |
| 60 | + </section> | |
| 61 | + | |
| 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"> | |
| 83 | + <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> | |
| 84 | + <input type="hidden" name="repo_id" value="{{ .RepoID }}"> | |
| 85 | + <input type="hidden" name="action" value="remove"> | |
| 86 | + <button type="submit" class="shithub-button shithub-button-danger">Remove</button> | |
| 87 | + </form> | |
| 88 | + {{ end }} | |
| 89 | + </li> | |
| 90 | + {{ end }} | |
| 91 | + </ol> | |
| 92 | + {{ else }} | |
| 93 | + <div class="shithub-org-empty"><h3>No repositories yet.</h3></div> | |
| 94 | + {{ end }} | |
| 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"> | |
| 12 | 129 | {{ if .IsOwner }} |
| 13 | - <section class="shithub-org-invite"> | |
| 130 | + <section class="shithub-org-team-manage-box"> | |
| 14 | 131 | <h2>Add member</h2> |
| 15 | 132 | <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/members"> |
| 16 | 133 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 17 | - <label><span>Username</span><input type="text" name="username" required></label> | |
| 134 | + <label><span>Username</span><input type="text" name="username" required placeholder="@username"></label> | |
| 18 | 135 | <label><span>Role</span> |
| 19 | 136 | <select name="role"> |
| 20 | 137 | <option value="member" selected>Member</option> |
| 21 | 138 | <option value="maintainer">Maintainer</option> |
| 22 | 139 | </select> |
| 23 | 140 | </label> |
| 24 | - <button type="submit" class="shithub-button shithub-button-primary">Add</button> | |
| 141 | + <button type="submit" class="shithub-button shithub-button-primary">Add member</button> | |
| 25 | 142 | </form> |
| 143 | + </section> | |
| 26 | 144 | |
| 27 | - <h2>Grant repo access</h2> | |
| 145 | + <section class="shithub-org-team-manage-box"> | |
| 146 | + <h2>Add repository</h2> | |
| 147 | + {{ if .RepoCandidates }} | |
| 28 | 148 | <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/repos"> |
| 29 | 149 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 30 | - <label><span>Repo ID</span><input type="number" name="repo_id" required min="1"></label> | |
| 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> | |
| 31 | 156 | <label><span>Role</span> |
| 32 | 157 | <select name="role"> |
| 33 | 158 | <option value="read">Read</option> |
@@ -37,66 +162,19 @@ | ||
| 37 | 162 | <option value="admin">Admin</option> |
| 38 | 163 | </select> |
| 39 | 164 | </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> | |
| 165 | + <button type="submit" class="shithub-button shithub-button-primary">Add repository</button> | |
| 62 | 166 | </form> |
| 63 | - </td> | |
| 64 | - {{ end }} | |
| 65 | - </tr> | |
| 167 | + {{ else }} | |
| 168 | + <p class="shithub-muted">Every organization repository is already linked to this team.</p> | |
| 66 | 169 | {{ end }} |
| 67 | - </tbody> | |
| 68 | - </table> | |
| 69 | 170 | </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 | 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> | |
| 100 | 176 | </section> |
| 177 | + </aside> | |
| 178 | + </div> | |
| 101 | 179 | </section> |
| 102 | 180 | {{- end }} |
internal/web/templates/orgs/teams_list.htmlmodified@@ -1,18 +1,32 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | -<section class="shithub-org-teams"> | |
| 3 | - <header class="shithub-org-profile-head"> | |
| 4 | - <h1>{{ .Org.DisplayName }} · Teams</h1> | |
| 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> | |
| 5 | 8 | <p class="shithub-meta">@{{ .Org.Slug }}</p> |
| 9 | + </div> | |
| 10 | + </div> | |
| 6 | 11 | </header> |
| 7 | 12 | |
| 13 | + {{ template "org-nav" . }} | |
| 14 | + | |
| 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> | |
| 8 | 22 | {{ if .IsOwner }} |
| 9 | - <section class="shithub-org-invite"> | |
| 10 | - <h2>New team</h2> | |
| 11 | - <form method="POST" action="/{{ .Org.Slug }}/teams"> | |
| 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"> | |
| 12 | 26 | <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> | |
| 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> | |
| 16 | 30 | <label><span>Privacy</span> |
| 17 | 31 | <select name="privacy"> |
| 18 | 32 | <option value="visible">Visible</option> |
@@ -21,27 +35,65 @@ | ||
| 21 | 35 | </label> |
| 22 | 36 | <button type="submit" class="shithub-button shithub-button-primary">Create team</button> |
| 23 | 37 | </form> |
| 24 | - </section> | |
| 38 | + </details> | |
| 25 | 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 }}&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 }}&q={{ urlquery .Query }}{{ end }}" class="{{ if eq .PrivacyFilter "secret" }}is-selected{{ end }}">Secret <span>{{ .SecretCount }}</span></a> | |
| 55 | + </nav> | |
| 26 | 56 | |
| 27 | - <section class="shithub-org-members"> | |
| 28 | - <h2>Teams ({{ len .Teams }})</h2> | |
| 29 | 57 | {{ if .Teams }} |
| 30 | - <ul class="shithub-repo-list"> | |
| 58 | + <ol class="shithub-org-team-list"> | |
| 31 | 59 | {{ 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 }} | |
| 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> | |
| 39 | 76 | </li> |
| 40 | 77 | {{ end }} |
| 41 | - </ul> | |
| 78 | + </ol> | |
| 42 | 79 | {{ else }} |
| 43 | - <p class="shithub-empty">No teams yet.</p> | |
| 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> | |
| 44 | 84 | {{ end }} |
| 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> | |
| 45 | 95 | </section> |
| 96 | + </aside> | |
| 97 | + </div> | |
| 46 | 98 | </section> |
| 47 | 99 | {{- end }} |
internal/web/templates/repo/settings_access.htmlmodified@@ -54,7 +54,7 @@ | ||
| 54 | 54 | {{ if .OwnerKindOrg }} |
| 55 | 55 | <section class="shithub-settings-section"> |
| 56 | 56 | <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> | |
| 58 | 58 | {{ if .TeamGrants }} |
| 59 | 59 | <table class="shithub-branches-table"> |
| 60 | 60 | <thead> |
@@ -63,7 +63,7 @@ | ||
| 63 | 63 | <tbody> |
| 64 | 64 | {{ range .TeamGrants }} |
| 65 | 65 | <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> | |
| 67 | 67 | <td>{{ .Role }}</td> |
| 68 | 68 | <td>{{ relativeTime .AddedAt.Time }}</td> |
| 69 | 69 | <td> |