| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package orgs |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/go-chi/chi/v5" |
| 14 | "github.com/jackc/pgx/v5/pgtype" |
| 15 | |
| 16 | "github.com/tenseleyFlow/shithub/internal/entitlements" |
| 17 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 18 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 19 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 20 | ) |
| 21 | |
| 22 | // MountTeams registers the per-org team surface. Read paths are |
| 23 | // public-but-filtered (secret teams hidden from non-members); |
| 24 | // mutations are owner-gated inside each handler. |
| 25 | func (h *Handlers) MountTeams(r chi.Router) { |
| 26 | r.Get("/{org}/teams", h.teamsList) |
| 27 | r.Post("/{org}/teams", h.teamCreate) |
| 28 | r.Get("/{org}/teams/{teamSlug}", h.teamView) |
| 29 | r.Post("/{org}/teams/{teamSlug}/members", h.teamMemberAddRemove) |
| 30 | r.Post("/{org}/teams/{teamSlug}/repos", h.teamRepoGrant) |
| 31 | } |
| 32 | |
| 33 | type orgNavCounts struct { |
| 34 | RepoCount int64 |
| 35 | MemberCount int64 |
| 36 | TeamCount int64 |
| 37 | } |
| 38 | |
| 39 | type teamAggregateCounts struct { |
| 40 | MemberCount int64 |
| 41 | RepoCount int64 |
| 42 | ChildCount int64 |
| 43 | } |
| 44 | |
| 45 | type teamListItem struct { |
| 46 | ID int64 |
| 47 | Slug string |
| 48 | DisplayName string |
| 49 | Description string |
| 50 | Privacy string |
| 51 | ParentSlug string |
| 52 | Path string |
| 53 | MemberCount int64 |
| 54 | RepoCount int64 |
| 55 | ChildCount int64 |
| 56 | IsSecret bool |
| 57 | HasParent bool |
| 58 | CreatedLabel string |
| 59 | } |
| 60 | |
| 61 | type teamRepoCandidate struct { |
| 62 | ID int64 |
| 63 | Name string |
| 64 | Visibility string |
| 65 | } |
| 66 | |
| 67 | type teamMemberCandidate struct { |
| 68 | ID int64 |
| 69 | Username string |
| 70 | DisplayName string |
| 71 | } |
| 72 | |
| 73 | // teamsList renders /{org}/teams. GitHub keeps org teams member-only: |
| 74 | // visible teams are visible to org members, while secret teams are |
| 75 | // further limited to team members and org owners. |
| 76 | func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) { |
| 77 | org, ok := h.orgFromSlug(w, r) |
| 78 | if !ok { |
| 79 | return |
| 80 | } |
| 81 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 82 | if !h.canSeeOrgTeams(w, r, org.ID, viewer) { |
| 83 | return |
| 84 | } |
| 85 | all, err := orgsdb.New().ListTeamsForOrg(r.Context(), h.d.Pool, org.ID) |
| 86 | if err != nil { |
| 87 | h.d.Logger.ErrorContext(r.Context(), "teams: list", "error", err) |
| 88 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 89 | return |
| 90 | } |
| 91 | visible := h.filterSecretTeams(r, all, org.ID, viewer) |
| 92 | counts := h.teamAggregateCounts(r.Context(), org.ID) |
| 93 | parentSlugs := teamParentSlugs(all) |
| 94 | items := h.teamListItems(org, visible, counts, parentSlugs) |
| 95 | visibleCount, secretCount := teamPrivacyCounts(items) |
| 96 | query := strings.TrimSpace(r.URL.Query().Get("q")) |
| 97 | privacy := strings.TrimSpace(r.URL.Query().Get("privacy")) |
| 98 | items = filterTeamListItems(items, query, privacy) |
| 99 | isOwner := false |
| 100 | canCreateSecretTeams := false |
| 101 | if !viewer.IsAnonymous() { |
| 102 | isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID) |
| 103 | if isOwner { |
| 104 | decision, derr := entitlements.CheckOrgFeature(r.Context(), entitlements.Deps{Pool: h.d.Pool}, org.ID, entitlements.FeatureOrgSecretTeams) |
| 105 | if derr != nil { |
| 106 | h.d.Logger.WarnContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "error", derr) |
| 107 | } else { |
| 108 | canCreateSecretTeams = decision.Allowed |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | navCounts := h.orgNavCounts(r.Context(), org.ID, int64(len(visible))) |
| 113 | if err := h.d.Render.RenderPage(w, r, "orgs/teams_list", map[string]any{ |
| 114 | "Title": org.Slug + " · teams", |
| 115 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 116 | "Org": org, |
| 117 | "AvatarURL": "/avatars/" + url.PathEscape(string(org.Slug)), |
| 118 | "ActiveOrgNav": "teams", |
| 119 | "Teams": items, |
| 120 | "TeamTotalCount": len(visible), |
| 121 | "VisibleCount": visibleCount, |
| 122 | "SecretCount": secretCount, |
| 123 | "Query": query, |
| 124 | "PrivacyFilter": privacy, |
| 125 | "RepoCount": navCounts.RepoCount, |
| 126 | "MemberCount": navCounts.MemberCount, |
| 127 | "TeamCount": navCounts.TeamCount, |
| 128 | "IsOwner": isOwner, |
| 129 | "Notice": teamsNoticeMessage(r.URL.Query().Get("notice")), |
| 130 | "CanCreateSecretTeams": canCreateSecretTeams, |
| 131 | }); err != nil { |
| 132 | h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/teams_list", "error", err) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // teamCreate handles POST /{org}/teams. Owner-only. |
| 137 | func (h *Handlers) teamCreate(w http.ResponseWriter, r *http.Request) { |
| 138 | org, ok := h.orgFromSlug(w, r) |
| 139 | if !ok { |
| 140 | return |
| 141 | } |
| 142 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 143 | if !h.requireOrgOwner(w, r, org.ID, viewer) { |
| 144 | return |
| 145 | } |
| 146 | if err := r.ParseForm(); err != nil { |
| 147 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 148 | return |
| 149 | } |
| 150 | privacy := strings.TrimSpace(r.PostFormValue("privacy")) |
| 151 | if privacy == "secret" { |
| 152 | decision, err := entitlements.CheckOrgFeature(r.Context(), entitlements.Deps{Pool: h.d.Pool}, org.ID, entitlements.FeatureOrgSecretTeams) |
| 153 | if err != nil { |
| 154 | h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "error", err) |
| 155 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 156 | return |
| 157 | } |
| 158 | if !decision.Allowed { |
| 159 | notice := "secret-teams-upgrade" |
| 160 | switch decision.Reason { |
| 161 | case entitlements.ReasonBillingActionNeeded: |
| 162 | notice = "secret-teams-billing" |
| 163 | case entitlements.ReasonEnterpriseContactSales: |
| 164 | notice = "secret-teams-enterprise" |
| 165 | } |
| 166 | http.Redirect(w, r, "/"+string(org.Slug)+"/teams?notice="+notice, http.StatusSeeOther) |
| 167 | return |
| 168 | } |
| 169 | } |
| 170 | parentID, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("parent_team_id")), 10, 64) |
| 171 | _, err := orgs.CreateTeam(r.Context(), h.deps(), orgs.CreateTeamParams{ |
| 172 | OrgID: org.ID, |
| 173 | Slug: strings.TrimSpace(r.PostFormValue("slug")), |
| 174 | DisplayName: strings.TrimSpace(r.PostFormValue("display_name")), |
| 175 | Description: strings.TrimSpace(r.PostFormValue("description")), |
| 176 | ParentTeamID: parentID, |
| 177 | Privacy: privacy, |
| 178 | CreatedByUserID: viewer.ID, |
| 179 | }) |
| 180 | if err != nil { |
| 181 | h.d.Logger.WarnContext(r.Context(), "teams: create", |
| 182 | "org", org.Slug, "error", err) |
| 183 | } |
| 184 | http.Redirect(w, r, "/"+string(org.Slug)+"/teams", http.StatusSeeOther) |
| 185 | } |
| 186 | |
| 187 | func teamsNoticeMessage(code string) string { |
| 188 | switch code { |
| 189 | case "secret-teams-upgrade": |
| 190 | return "Secret teams require Team billing. Upgrade this organization to create them." |
| 191 | case "secret-teams-billing": |
| 192 | return "Secret teams are read-only until Team billing is brought back into good standing." |
| 193 | case "secret-teams-enterprise": |
| 194 | return "Secret teams are unavailable for Enterprise preview organizations. Contact sales to enable them." |
| 195 | case "private-collab-upgrade": |
| 196 | return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators." |
| 197 | default: |
| 198 | return "" |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func (h *Handlers) secretTeamWriteNotice(ctx context.Context, orgID int64, team orgsdb.Team) (string, error) { |
| 203 | if team.Privacy != orgsdb.TeamPrivacySecret { |
| 204 | return "", nil |
| 205 | } |
| 206 | decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, orgID, entitlements.FeatureOrgSecretTeams) |
| 207 | if err != nil { |
| 208 | return "", err |
| 209 | } |
| 210 | if decision.Allowed { |
| 211 | return "", nil |
| 212 | } |
| 213 | switch decision.Reason { |
| 214 | case entitlements.ReasonBillingActionNeeded: |
| 215 | return "secret-teams-billing", nil |
| 216 | case entitlements.ReasonEnterpriseContactSales: |
| 217 | return "secret-teams-enterprise", nil |
| 218 | default: |
| 219 | return "secret-teams-upgrade", nil |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // teamView renders /{org}/teams/{teamSlug}. Members + repo access. |
| 224 | // Secret teams 404 for non-members + non-owners. |
| 225 | func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) { |
| 226 | org, ok := h.orgFromSlug(w, r) |
| 227 | if !ok { |
| 228 | return |
| 229 | } |
| 230 | team, ok := h.teamFromSlug(w, r, org.ID) |
| 231 | if !ok { |
| 232 | return |
| 233 | } |
| 234 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 235 | if !h.canSeeOrgTeams(w, r, org.ID, viewer) { |
| 236 | return |
| 237 | } |
| 238 | if !h.canSeeTeam(r, team, viewer) { |
| 239 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 240 | return |
| 241 | } |
| 242 | q := orgsdb.New() |
| 243 | members, _ := q.ListTeamMembers(r.Context(), h.d.Pool, team.ID) |
| 244 | repos, _ := q.ListTeamRepoAccess(r.Context(), h.d.Pool, team.ID) |
| 245 | children, _ := q.ListChildTeams(r.Context(), h.d.Pool, pgtype.Int8{Int64: team.ID, Valid: true}) |
| 246 | childItems := h.teamListItems(org, h.filterSecretTeams(r, children, org.ID, viewer), |
| 247 | h.teamAggregateCounts(r.Context(), org.ID), teamParentSlugs(children)) |
| 248 | memberCandidates := h.teamMemberCandidates(r.Context(), org.ID, team.ID) |
| 249 | repoCandidates := h.teamRepoCandidates(r.Context(), org.ID, team.ID) |
| 250 | isOwner := false |
| 251 | canExpandTeam := false |
| 252 | secretTeamWritesDisabledMessage := "" |
| 253 | if !viewer.IsAnonymous() { |
| 254 | isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID) |
| 255 | canExpandTeam = isOwner |
| 256 | if isOwner { |
| 257 | noticeCode, nerr := h.secretTeamWriteNotice(r.Context(), org.ID, team) |
| 258 | if nerr != nil { |
| 259 | h.d.Logger.WarnContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", nerr) |
| 260 | canExpandTeam = false |
| 261 | secretTeamWritesDisabledMessage = "Secret team changes are temporarily unavailable." |
| 262 | } else if noticeCode != "" { |
| 263 | canExpandTeam = false |
| 264 | secretTeamWritesDisabledMessage = teamsNoticeMessage(noticeCode) |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | navCounts := h.orgNavCounts(r.Context(), org.ID, -1) |
| 269 | if err := h.d.Render.RenderPage(w, r, "orgs/team_view", map[string]any{ |
| 270 | "Title": string(org.Slug) + "/" + string(team.Slug), |
| 271 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 272 | "Org": org, |
| 273 | "AvatarURL": "/avatars/" + url.PathEscape(string(org.Slug)), |
| 274 | "ActiveOrgNav": "teams", |
| 275 | "Team": team, |
| 276 | "TeamDisplayName": teamDisplayName(team), |
| 277 | "TeamPath": h.teamPath(org, team), |
| 278 | "TeamPrivacy": string(team.Privacy), |
| 279 | "TeamIsSecret": team.Privacy == orgsdb.TeamPrivacySecret, |
| 280 | "ChildTeams": childItems, |
| 281 | "Members": members, |
| 282 | "MemberCandidates": memberCandidates, |
| 283 | "Repos": repos, |
| 284 | "RepoCandidates": repoCandidates, |
| 285 | "RepoCount": navCounts.RepoCount, |
| 286 | "MemberCount": navCounts.MemberCount, |
| 287 | "TeamCount": navCounts.TeamCount, |
| 288 | "IsOwner": isOwner, |
| 289 | "CanExpandTeam": canExpandTeam, |
| 290 | "SecretTeamWritesDisabledMessage": secretTeamWritesDisabledMessage, |
| 291 | "Notice": teamsNoticeMessage(r.URL.Query().Get("notice")), |
| 292 | }); err != nil { |
| 293 | h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/team_view", "error", err) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | // teamMemberAddRemove handles POST .../members. Form action=add|remove. |
| 298 | // Both branches are owner-only; the orchestrator keeps idempotency. |
| 299 | func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) { |
| 300 | org, ok := h.orgFromSlug(w, r) |
| 301 | if !ok { |
| 302 | return |
| 303 | } |
| 304 | team, ok := h.teamFromSlug(w, r, org.ID) |
| 305 | if !ok { |
| 306 | return |
| 307 | } |
| 308 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 309 | if !h.requireOrgOwner(w, r, org.ID, viewer) { |
| 310 | return |
| 311 | } |
| 312 | if err := r.ParseForm(); err != nil { |
| 313 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 314 | return |
| 315 | } |
| 316 | action := r.PostFormValue("action") |
| 317 | uid, ok := h.userIDFromTeamMemberForm(r) |
| 318 | if !ok { |
| 319 | http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) |
| 320 | return |
| 321 | } |
| 322 | if action != "remove" && !h.userIsOrgMember(r.Context(), org.ID, uid) { |
| 323 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 324 | return |
| 325 | } |
| 326 | switch action { |
| 327 | case "remove": |
| 328 | _ = orgs.RemoveTeamMember(r.Context(), h.deps(), team.ID, uid) |
| 329 | default: |
| 330 | noticeCode, err := h.secretTeamWriteNotice(r.Context(), org.ID, team) |
| 331 | if err != nil { |
| 332 | h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", err) |
| 333 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 334 | return |
| 335 | } |
| 336 | if noticeCode != "" { |
| 337 | http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther) |
| 338 | return |
| 339 | } |
| 340 | role := r.PostFormValue("role") |
| 341 | if err := orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role); err != nil { |
| 342 | h.d.Logger.WarnContext(r.Context(), "teams: add member", "org_id", org.ID, "team_id", team.ID, "user_id", uid, "error", err) |
| 343 | if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { |
| 344 | http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther) |
| 345 | return |
| 346 | } |
| 347 | } |
| 348 | } |
| 349 | http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) |
| 350 | } |
| 351 | |
| 352 | // teamRepoGrant handles POST .../repos. Form expects repo_id + role, |
| 353 | // or repo_id + action=remove. Owner-only. |
| 354 | func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) { |
| 355 | org, ok := h.orgFromSlug(w, r) |
| 356 | if !ok { |
| 357 | return |
| 358 | } |
| 359 | team, ok := h.teamFromSlug(w, r, org.ID) |
| 360 | if !ok { |
| 361 | return |
| 362 | } |
| 363 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 364 | if !h.requireOrgOwner(w, r, org.ID, viewer) { |
| 365 | return |
| 366 | } |
| 367 | if err := r.ParseForm(); err != nil { |
| 368 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 369 | return |
| 370 | } |
| 371 | repoID, err := h.repoIDFromTeamForm(r, org.ID) |
| 372 | if err != nil || repoID == 0 { |
| 373 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 374 | return |
| 375 | } |
| 376 | if !h.repoBelongsToOrg(r.Context(), org.ID, repoID) { |
| 377 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 378 | return |
| 379 | } |
| 380 | if r.PostFormValue("action") == "remove" { |
| 381 | _ = orgs.RevokeTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID) |
| 382 | } else { |
| 383 | noticeCode, err := h.secretTeamWriteNotice(r.Context(), org.ID, team) |
| 384 | if err != nil { |
| 385 | h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", err) |
| 386 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 387 | return |
| 388 | } |
| 389 | if noticeCode != "" { |
| 390 | http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther) |
| 391 | return |
| 392 | } |
| 393 | if err := orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID, |
| 394 | r.PostFormValue("role")); err != nil { |
| 395 | h.d.Logger.WarnContext(r.Context(), "teams: grant repo", "org_id", org.ID, "team_id", team.ID, "repo_id", repoID, "error", err) |
| 396 | if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { |
| 397 | http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther) |
| 398 | return |
| 399 | } |
| 400 | } |
| 401 | } |
| 402 | http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) |
| 403 | } |
| 404 | |
| 405 | // ─── small helpers ───────────────────────────────────────────────── |
| 406 | |
| 407 | func (h *Handlers) teamFromSlug(w http.ResponseWriter, r *http.Request, orgID int64) (orgsdb.Team, bool) { |
| 408 | slug := chi.URLParam(r, "teamSlug") |
| 409 | row, err := orgsdb.New().GetTeamByOrgAndSlug(r.Context(), h.d.Pool, orgsdb.GetTeamByOrgAndSlugParams{ |
| 410 | OrgID: orgID, Slug: slug, |
| 411 | }) |
| 412 | if err != nil { |
| 413 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 414 | return orgsdb.Team{}, false |
| 415 | } |
| 416 | return row, true |
| 417 | } |
| 418 | |
| 419 | func (h *Handlers) canSeeOrgTeams(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool { |
| 420 | if viewer.IsAnonymous() { |
| 421 | http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| 422 | return false |
| 423 | } |
| 424 | if viewer.IsSiteAdmin { |
| 425 | return true |
| 426 | } |
| 427 | isMember, _ := orgs.IsMember(r.Context(), h.deps(), orgID, viewer.ID) |
| 428 | if !isMember { |
| 429 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 430 | return false |
| 431 | } |
| 432 | return true |
| 433 | } |
| 434 | |
| 435 | func (h *Handlers) requireOrgOwner(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool { |
| 436 | if viewer.IsAnonymous() { |
| 437 | http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther) |
| 438 | return false |
| 439 | } |
| 440 | // Suspended actors get the same 403 as non-owners. Mirrors the |
| 441 | // suspended gate the policy package enforces on every other |
| 442 | // mutation surface — this gate doesn't go through policy.Can yet |
| 443 | // (the org/team actions aren't in the policy enum), so we |
| 444 | // short-circuit here (SR2 C4). Same shape as SR1 C1 fix. |
| 445 | if viewer.IsSuspended { |
| 446 | h.d.Render.HTTPError(w, r, http.StatusForbidden, "") |
| 447 | return false |
| 448 | } |
| 449 | owner, _ := orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID) |
| 450 | if !owner { |
| 451 | h.d.Render.HTTPError(w, r, http.StatusForbidden, "") |
| 452 | return false |
| 453 | } |
| 454 | return true |
| 455 | } |
| 456 | |
| 457 | func (h *Handlers) userIDByUsername(r *http.Request, username string) (int64, bool) { |
| 458 | var id int64 |
| 459 | err := h.d.Pool.QueryRow( |
| 460 | r.Context(), |
| 461 | `SELECT id FROM users WHERE username = $1 AND deleted_at IS NULL`, |
| 462 | username, |
| 463 | ).Scan(&id) |
| 464 | if err != nil { |
| 465 | return 0, false |
| 466 | } |
| 467 | return id, true |
| 468 | } |
| 469 | |
| 470 | func (h *Handlers) userIDFromTeamMemberForm(r *http.Request) (int64, bool) { |
| 471 | if raw := strings.TrimSpace(r.PostFormValue("user_id")); raw != "" { |
| 472 | id, err := strconv.ParseInt(raw, 10, 64) |
| 473 | if err == nil && id != 0 { |
| 474 | return id, true |
| 475 | } |
| 476 | return 0, false |
| 477 | } |
| 478 | username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))) |
| 479 | if username == "" { |
| 480 | return 0, false |
| 481 | } |
| 482 | return h.userIDByUsername(r, username) |
| 483 | } |
| 484 | |
| 485 | func (h *Handlers) userIsOrgMember(ctx context.Context, orgID, userID int64) bool { |
| 486 | var exists bool |
| 487 | err := h.d.Pool.QueryRow(ctx, |
| 488 | `SELECT EXISTS(SELECT 1 FROM org_members WHERE org_id = $1 AND user_id = $2)`, |
| 489 | orgID, userID, |
| 490 | ).Scan(&exists) |
| 491 | return err == nil && exists |
| 492 | } |
| 493 | |
| 494 | // canSeeTeam decides whether the viewer is allowed to see a team's members |
| 495 | // and repositories. canSeeOrgTeams has already enforced org membership; |
| 496 | // visible teams are readable to those members, while secret teams require |
| 497 | // team membership or org ownership. |
| 498 | func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middleware.CurrentUser) bool { |
| 499 | if team.Privacy == orgsdb.TeamPrivacyVisible { |
| 500 | return true |
| 501 | } |
| 502 | if viewer.IsAnonymous() { |
| 503 | return false |
| 504 | } |
| 505 | // Org owner sees all. |
| 506 | if owner, _ := orgs.IsOwner(r.Context(), h.deps(), team.OrgID, viewer.ID); owner { |
| 507 | return true |
| 508 | } |
| 509 | // Team member? (SR2 M2 + M3: was an inline EXISTS preceded by a |
| 510 | // wasted ListTeamMembers call whose result was dropped with `_`.) |
| 511 | member, err := orgsdb.New().IsTeamMember(r.Context(), h.d.Pool, orgsdb.IsTeamMemberParams{ |
| 512 | TeamID: team.ID, UserID: viewer.ID, |
| 513 | }) |
| 514 | if err != nil { |
| 515 | return false |
| 516 | } |
| 517 | return member |
| 518 | } |
| 519 | |
| 520 | // filterSecretTeams strips secret teams the viewer can't see after the |
| 521 | // caller has already established org-team-page visibility. |
| 522 | func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID int64, viewer middleware.CurrentUser) []orgsdb.Team { |
| 523 | if len(all) == 0 { |
| 524 | return all |
| 525 | } |
| 526 | out := make([]orgsdb.Team, 0, len(all)) |
| 527 | isOwner := false |
| 528 | if !viewer.IsAnonymous() { |
| 529 | isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID) |
| 530 | } |
| 531 | for _, t := range all { |
| 532 | if t.Privacy == orgsdb.TeamPrivacyVisible || isOwner { |
| 533 | out = append(out, t) |
| 534 | continue |
| 535 | } |
| 536 | // Secret + non-owner: only show when the viewer is a member. |
| 537 | if viewer.IsAnonymous() { |
| 538 | continue |
| 539 | } |
| 540 | // SR2 M2: was an inline EXISTS query. |
| 541 | member, err := orgsdb.New().IsTeamMember(r.Context(), h.d.Pool, orgsdb.IsTeamMemberParams{ |
| 542 | TeamID: t.ID, UserID: viewer.ID, |
| 543 | }) |
| 544 | if err == nil && member { |
| 545 | out = append(out, t) |
| 546 | } |
| 547 | } |
| 548 | return out |
| 549 | } |
| 550 | |
| 551 | func (h *Handlers) orgNavCounts(ctx context.Context, orgID int64, visibleTeamCount int64) orgNavCounts { |
| 552 | var counts orgNavCounts |
| 553 | _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM repos WHERE owner_org_id = $1 AND deleted_at IS NULL`, orgID).Scan(&counts.RepoCount) |
| 554 | _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID).Scan(&counts.MemberCount) |
| 555 | if visibleTeamCount >= 0 { |
| 556 | counts.TeamCount = visibleTeamCount |
| 557 | } else { |
| 558 | _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM teams WHERE org_id = $1`, orgID).Scan(&counts.TeamCount) |
| 559 | } |
| 560 | return counts |
| 561 | } |
| 562 | |
| 563 | func (h *Handlers) teamAggregateCounts(ctx context.Context, orgID int64) map[int64]teamAggregateCounts { |
| 564 | rows, err := h.d.Pool.Query(ctx, ` |
| 565 | SELECT t.id, |
| 566 | count(DISTINCT tm.user_id)::bigint AS member_count, |
| 567 | count(DISTINCT tra.repo_id)::bigint AS repo_count, |
| 568 | count(DISTINCT child.id)::bigint AS child_count |
| 569 | FROM teams t |
| 570 | LEFT JOIN team_members tm ON tm.team_id = t.id |
| 571 | LEFT JOIN team_repo_access tra ON tra.team_id = t.id |
| 572 | LEFT JOIN teams child ON child.parent_team_id = t.id |
| 573 | WHERE t.org_id = $1 |
| 574 | GROUP BY t.id`, orgID) |
| 575 | if err != nil { |
| 576 | h.d.Logger.WarnContext(ctx, "teams: counts", "org_id", orgID, "error", err) |
| 577 | return nil |
| 578 | } |
| 579 | defer rows.Close() |
| 580 | out := map[int64]teamAggregateCounts{} |
| 581 | for rows.Next() { |
| 582 | var id int64 |
| 583 | var c teamAggregateCounts |
| 584 | if err := rows.Scan(&id, &c.MemberCount, &c.RepoCount, &c.ChildCount); err == nil { |
| 585 | out[id] = c |
| 586 | } |
| 587 | } |
| 588 | return out |
| 589 | } |
| 590 | |
| 591 | func (h *Handlers) teamListItems(org orgsdb.Org, teams []orgsdb.Team, counts map[int64]teamAggregateCounts, parentSlugs map[int64]string) []teamListItem { |
| 592 | out := make([]teamListItem, 0, len(teams)) |
| 593 | for _, team := range teams { |
| 594 | c := counts[team.ID] |
| 595 | parentSlug := "" |
| 596 | if team.ParentTeamID.Valid { |
| 597 | parentSlug = parentSlugs[team.ParentTeamID.Int64] |
| 598 | } |
| 599 | out = append(out, teamListItem{ |
| 600 | ID: team.ID, |
| 601 | Slug: string(team.Slug), |
| 602 | DisplayName: teamDisplayName(team), |
| 603 | Description: team.Description, |
| 604 | Privacy: string(team.Privacy), |
| 605 | ParentSlug: parentSlug, |
| 606 | Path: h.teamPath(org, team), |
| 607 | MemberCount: c.MemberCount, |
| 608 | RepoCount: c.RepoCount, |
| 609 | ChildCount: c.ChildCount, |
| 610 | IsSecret: team.Privacy == orgsdb.TeamPrivacySecret, |
| 611 | HasParent: team.ParentTeamID.Valid, |
| 612 | CreatedLabel: team.CreatedAt.Time.Format("Jan 2, 2006"), |
| 613 | }) |
| 614 | } |
| 615 | return out |
| 616 | } |
| 617 | |
| 618 | func teamParentSlugs(teams []orgsdb.Team) map[int64]string { |
| 619 | if len(teams) == 0 { |
| 620 | return nil |
| 621 | } |
| 622 | byID := make(map[int64]string, len(teams)) |
| 623 | for _, team := range teams { |
| 624 | byID[team.ID] = string(team.Slug) |
| 625 | } |
| 626 | return byID |
| 627 | } |
| 628 | |
| 629 | func teamDisplayName(team orgsdb.Team) string { |
| 630 | if strings.TrimSpace(team.DisplayName) != "" { |
| 631 | return team.DisplayName |
| 632 | } |
| 633 | return string(team.Slug) |
| 634 | } |
| 635 | |
| 636 | func teamPrivacyCounts(items []teamListItem) (visibleCount, secretCount int) { |
| 637 | for _, item := range items { |
| 638 | if item.IsSecret { |
| 639 | secretCount++ |
| 640 | } else { |
| 641 | visibleCount++ |
| 642 | } |
| 643 | } |
| 644 | return visibleCount, secretCount |
| 645 | } |
| 646 | |
| 647 | func filterTeamListItems(items []teamListItem, query, privacy string) []teamListItem { |
| 648 | query = strings.ToLower(strings.TrimSpace(query)) |
| 649 | privacy = strings.ToLower(strings.TrimSpace(privacy)) |
| 650 | if query == "" && privacy == "" { |
| 651 | return items |
| 652 | } |
| 653 | out := make([]teamListItem, 0, len(items)) |
| 654 | for _, item := range items { |
| 655 | if privacy == "visible" && item.IsSecret { |
| 656 | continue |
| 657 | } |
| 658 | if privacy == "secret" && !item.IsSecret { |
| 659 | continue |
| 660 | } |
| 661 | if query != "" { |
| 662 | haystack := strings.ToLower(item.Slug + " " + item.DisplayName + " " + item.Description) |
| 663 | if !strings.Contains(haystack, query) { |
| 664 | continue |
| 665 | } |
| 666 | } |
| 667 | out = append(out, item) |
| 668 | } |
| 669 | return out |
| 670 | } |
| 671 | |
| 672 | func (h *Handlers) teamRepoCandidates(ctx context.Context, orgID, teamID int64) []teamRepoCandidate { |
| 673 | rows, err := h.d.Pool.Query(ctx, ` |
| 674 | SELECT r.id, r.name, r.visibility::text |
| 675 | FROM repos r |
| 676 | LEFT JOIN team_repo_access a |
| 677 | ON a.repo_id = r.id AND a.team_id = $2 |
| 678 | WHERE r.owner_org_id = $1 |
| 679 | AND r.deleted_at IS NULL |
| 680 | AND a.repo_id IS NULL |
| 681 | ORDER BY lower(r.name) |
| 682 | LIMIT 100`, orgID, teamID) |
| 683 | if err != nil { |
| 684 | h.d.Logger.WarnContext(ctx, "teams: repo candidates", "org_id", orgID, "team_id", teamID, "error", err) |
| 685 | return nil |
| 686 | } |
| 687 | defer rows.Close() |
| 688 | out := []teamRepoCandidate{} |
| 689 | for rows.Next() { |
| 690 | var item teamRepoCandidate |
| 691 | if err := rows.Scan(&item.ID, &item.Name, &item.Visibility); err == nil { |
| 692 | out = append(out, item) |
| 693 | } |
| 694 | } |
| 695 | return out |
| 696 | } |
| 697 | |
| 698 | func (h *Handlers) teamMemberCandidates(ctx context.Context, orgID, teamID int64) []teamMemberCandidate { |
| 699 | rows, err := h.d.Pool.Query(ctx, ` |
| 700 | SELECT u.id, u.username, u.display_name |
| 701 | FROM org_members om |
| 702 | JOIN users u ON u.id = om.user_id |
| 703 | LEFT JOIN team_members tm |
| 704 | ON tm.team_id = $2 AND tm.user_id = u.id |
| 705 | WHERE om.org_id = $1 |
| 706 | AND u.deleted_at IS NULL |
| 707 | AND tm.user_id IS NULL |
| 708 | ORDER BY lower(u.username) |
| 709 | LIMIT 100`, orgID, teamID) |
| 710 | if err != nil { |
| 711 | h.d.Logger.WarnContext(ctx, "teams: member candidates", "org_id", orgID, "team_id", teamID, "error", err) |
| 712 | return nil |
| 713 | } |
| 714 | defer rows.Close() |
| 715 | out := []teamMemberCandidate{} |
| 716 | for rows.Next() { |
| 717 | var item teamMemberCandidate |
| 718 | if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName); err == nil { |
| 719 | out = append(out, item) |
| 720 | } |
| 721 | } |
| 722 | return out |
| 723 | } |
| 724 | |
| 725 | func (h *Handlers) repoIDFromTeamForm(r *http.Request, orgID int64) (int64, error) { |
| 726 | if raw := strings.TrimSpace(r.PostFormValue("repo_id")); raw != "" { |
| 727 | return strconv.ParseInt(raw, 10, 64) |
| 728 | } |
| 729 | repoName := strings.TrimSpace(r.PostFormValue("repo_name")) |
| 730 | if repoName == "" { |
| 731 | return 0, strconv.ErrSyntax |
| 732 | } |
| 733 | var id int64 |
| 734 | err := h.d.Pool.QueryRow( |
| 735 | r.Context(), |
| 736 | `SELECT id FROM repos WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL`, |
| 737 | orgID, repoName, |
| 738 | ).Scan(&id) |
| 739 | return id, err |
| 740 | } |
| 741 | |
| 742 | func (h *Handlers) repoBelongsToOrg(ctx context.Context, orgID, repoID int64) bool { |
| 743 | var exists bool |
| 744 | err := h.d.Pool.QueryRow(ctx, |
| 745 | `SELECT EXISTS(SELECT 1 FROM repos WHERE id = $1 AND owner_org_id = $2 AND deleted_at IS NULL)`, |
| 746 | repoID, orgID, |
| 747 | ).Scan(&exists) |
| 748 | return err == nil && exists |
| 749 | } |
| 750 | |
| 751 | func (h *Handlers) teamPath(org orgsdb.Org, team orgsdb.Team) string { |
| 752 | return "/" + string(org.Slug) + "/teams/" + string(team.Slug) |
| 753 | } |
| 754 |