Use org member picker for teams
- SHA
81ee7f95decc174890086579a74a1f580725c938- Parents
-
f926e68 - Tree
ece5615
81ee7f9
81ee7f95decc174890086579a74a1f580725c938f926e68
ece5615| Status | File | + | - |
|---|---|---|---|
| M |
internal/web/handlers/orgs/teams.go
|
81 | 23 |
| M |
internal/web/handlers/orgs/teams_test.go
|
38 | 1 |
| M |
internal/web/templates/orgs/team_view.html
|
11 | 2 |
internal/web/handlers/orgs/teams.gomodified@@ -62,6 +62,12 @@ type teamRepoCandidate struct { | ||
| 62 | 62 | Visibility string |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | +type teamMemberCandidate struct { | |
| 66 | + ID int64 | |
| 67 | + Username string | |
| 68 | + DisplayName string | |
| 69 | +} | |
| 70 | + | |
| 65 | 71 | // teamsList renders /{org}/teams. GitHub keeps org teams member-only: |
| 66 | 72 | // visible teams are visible to org members, while secret teams are |
| 67 | 73 | // further limited to team members and org owners. |
@@ -168,6 +174,7 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) { | ||
| 168 | 174 | children, _ := q.ListChildTeams(r.Context(), h.d.Pool, pgtype.Int8{Int64: team.ID, Valid: true}) |
| 169 | 175 | childItems := h.teamListItems(org, h.filterSecretTeams(r, children, org.ID, viewer), |
| 170 | 176 | h.teamAggregateCounts(r.Context(), org.ID), teamParentSlugs(children)) |
| 177 | + memberCandidates := h.teamMemberCandidates(r.Context(), org.ID, team.ID) | |
| 171 | 178 | repoCandidates := h.teamRepoCandidates(r.Context(), org.ID, team.ID) |
| 172 | 179 | isOwner := false |
| 173 | 180 | if !viewer.IsAnonymous() { |
@@ -186,6 +193,7 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) { | ||
| 186 | 193 | "TeamIsSecret": team.Privacy == orgsdb.TeamPrivacySecret, |
| 187 | 194 | "ChildTeams": childItems, |
| 188 | 195 | "Members": members, |
| 196 | + "MemberCandidates": memberCandidates, | |
| 189 | 197 | "Repos": repos, |
| 190 | 198 | "RepoCandidates": repoCandidates, |
| 191 | 199 | "ActiveOrgTab": "teams", |
@@ -215,15 +223,14 @@ func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) { | ||
| 215 | 223 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 216 | 224 | return |
| 217 | 225 | } |
| 218 | - username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))) | |
| 219 | 226 | action := r.PostFormValue("action") |
| 220 | - if username == "" { | |
| 227 | + uid, ok := h.userIDFromTeamMemberForm(r) | |
| 228 | + if !ok { | |
| 221 | 229 | http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) |
| 222 | 230 | return |
| 223 | 231 | } |
| 224 | - uid, ok := h.userIDByUsername(r, username) | |
| 225 | - if !ok { | |
| 226 | - http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) | |
| 232 | + if action != "remove" && !h.userIsOrgMember(r.Context(), org.ID, uid) { | |
| 233 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") | |
| 227 | 234 | return |
| 228 | 235 | } |
| 229 | 236 | switch action { |
@@ -329,6 +336,30 @@ func (h *Handlers) userIDByUsername(r *http.Request, username string) (int64, bo | ||
| 329 | 336 | return id, true |
| 330 | 337 | } |
| 331 | 338 | |
| 339 | +func (h *Handlers) userIDFromTeamMemberForm(r *http.Request) (int64, bool) { | |
| 340 | + if raw := strings.TrimSpace(r.PostFormValue("user_id")); raw != "" { | |
| 341 | + id, err := strconv.ParseInt(raw, 10, 64) | |
| 342 | + if err == nil && id != 0 { | |
| 343 | + return id, true | |
| 344 | + } | |
| 345 | + return 0, false | |
| 346 | + } | |
| 347 | + username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))) | |
| 348 | + if username == "" { | |
| 349 | + return 0, false | |
| 350 | + } | |
| 351 | + return h.userIDByUsername(r, username) | |
| 352 | +} | |
| 353 | + | |
| 354 | +func (h *Handlers) userIsOrgMember(ctx context.Context, orgID, userID int64) bool { | |
| 355 | + var exists bool | |
| 356 | + err := h.d.Pool.QueryRow(ctx, | |
| 357 | + `SELECT EXISTS(SELECT 1 FROM org_members WHERE org_id = $1 AND user_id = $2)`, | |
| 358 | + orgID, userID, | |
| 359 | + ).Scan(&exists) | |
| 360 | + return err == nil && exists | |
| 361 | +} | |
| 362 | + | |
| 332 | 363 | // canSeeTeam decides whether the viewer is allowed to see a team's members |
| 333 | 364 | // and repositories. canSeeOrgTeams has already enforced org membership; |
| 334 | 365 | // visible teams are readable to those members, while secret teams require |
@@ -538,6 +569,33 @@ func (h *Handlers) teamRepoCandidates(ctx context.Context, orgID, teamID int64) | ||
| 538 | 569 | return out |
| 539 | 570 | } |
| 540 | 571 | |
| 572 | +func (h *Handlers) teamMemberCandidates(ctx context.Context, orgID, teamID int64) []teamMemberCandidate { | |
| 573 | + rows, err := h.d.Pool.Query(ctx, ` | |
| 574 | + SELECT u.id, u.username, u.display_name | |
| 575 | + FROM org_members om | |
| 576 | + JOIN users u ON u.id = om.user_id | |
| 577 | + LEFT JOIN team_members tm | |
| 578 | + ON tm.team_id = $2 AND tm.user_id = u.id | |
| 579 | + WHERE om.org_id = $1 | |
| 580 | + AND u.deleted_at IS NULL | |
| 581 | + AND tm.user_id IS NULL | |
| 582 | + ORDER BY lower(u.username) | |
| 583 | + LIMIT 100`, orgID, teamID) | |
| 584 | + if err != nil { | |
| 585 | + h.d.Logger.WarnContext(ctx, "teams: member candidates", "org_id", orgID, "team_id", teamID, "error", err) | |
| 586 | + return nil | |
| 587 | + } | |
| 588 | + defer rows.Close() | |
| 589 | + out := []teamMemberCandidate{} | |
| 590 | + for rows.Next() { | |
| 591 | + var item teamMemberCandidate | |
| 592 | + if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName); err == nil { | |
| 593 | + out = append(out, item) | |
| 594 | + } | |
| 595 | + } | |
| 596 | + return out | |
| 597 | +} | |
| 598 | + | |
| 541 | 599 | func (h *Handlers) repoIDFromTeamForm(r *http.Request, orgID int64) (int64, error) { |
| 542 | 600 | if raw := strings.TrimSpace(r.PostFormValue("repo_id")); raw != "" { |
| 543 | 601 | return strconv.ParseInt(raw, 10, 64) |
internal/web/handlers/orgs/teams_test.gomodified@@ -8,6 +8,8 @@ import ( | ||
| 8 | 8 | "log/slog" |
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | + "net/url" | |
| 12 | + "strconv" | |
| 11 | 13 | "strings" |
| 12 | 14 | "testing" |
| 13 | 15 | "testing/fstest" |
@@ -64,7 +66,34 @@ func TestTeamsListRequiresOrgMemberAndFiltersSecretTeams(t *testing.T) { | ||
| 64 | 66 | } |
| 65 | 67 | } |
| 66 | 68 | |
| 69 | +func TestTeamMemberAddRejectsNonOrgUsers(t *testing.T) { | |
| 70 | + t.Parallel() | |
| 71 | + ctx := context.Background() | |
| 72 | + pool := dbtest.NewTestDB(t) | |
| 73 | + ownerID := insertOrgAvatarUser(t, pool, "owner") | |
| 74 | + outsiderID := insertOrgAvatarUser(t, pool, "outsider") | |
| 75 | + orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme") | |
| 76 | + teamID := insertTeamForTest(t, pool, orgID, "engineering", "Engineering", "visible") | |
| 77 | + | |
| 78 | + form := url.Values{"user_id": {strconv.FormatInt(outsiderID, 10)}, "role": {"member"}} | |
| 79 | + body, status, _ := performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/engineering/members", form) | |
| 80 | + if status != http.StatusBadRequest { | |
| 81 | + t.Fatalf("status=%d body=%s", status, body) | |
| 82 | + } | |
| 83 | + var count int | |
| 84 | + if err := pool.QueryRow(ctx, `SELECT count(*) FROM team_members WHERE team_id = $1`, teamID).Scan(&count); err != nil { | |
| 85 | + t.Fatalf("count team members: %v", err) | |
| 86 | + } | |
| 87 | + if count != 0 { | |
| 88 | + t.Fatalf("expected no team member insert, got %d", count) | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 67 | 92 | func performTeamsListRequest(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, target string) (string, int, string) { |
| 93 | + return performTeamsRequest(t, pool, viewer, http.MethodGet, target, nil) | |
| 94 | +} | |
| 95 | + | |
| 96 | +func performTeamsRequest(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, method, target string, form url.Values) (string, int, string) { | |
| 68 | 97 | t.Helper() |
| 69 | 98 | rr, err := render.New(fstest.MapFS{ |
| 70 | 99 | "_layout.html": {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)}, |
@@ -72,6 +101,7 @@ func performTeamsListRequest(t *testing.T, pool *pgxpool.Pool, viewer middleware | ||
| 72 | 101 | "orgs/teams_list.html": {Data: []byte(`{{ define "page" }}{{ template "org-nav" . }} TOTAL={{ .TeamTotalCount }}{{ range .Teams }} TEAM={{ .Slug }}:{{ .DisplayName }}:{{ .MemberCount }}:{{ .RepoCount }}{{ end }}{{ end }}`)}, |
| 73 | 102 | "orgs/team_view.html": {Data: []byte(`{{ define "page" }}TEAM{{ end }}`)}, |
| 74 | 103 | "orgs/people.html": {Data: []byte(`{{ define "page" }}PEOPLE{{ end }}`)}, |
| 104 | + "errors/400.html": {Data: []byte(`{{ define "page" }}400{{ end }}`)}, | |
| 75 | 105 | "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)}, |
| 76 | 106 | "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, |
| 77 | 107 | "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, |
@@ -95,7 +125,14 @@ func performTeamsListRequest(t *testing.T, pool *pgxpool.Pool, viewer middleware | ||
| 95 | 125 | }) |
| 96 | 126 | h.MountOrgRoutes(r) |
| 97 | 127 | |
| 98 | - req := httptest.NewRequest(http.MethodGet, target, nil) | |
| 128 | + var body io.Reader | |
| 129 | + if form != nil { | |
| 130 | + body = strings.NewReader(form.Encode()) | |
| 131 | + } | |
| 132 | + req := httptest.NewRequest(method, target, body) | |
| 133 | + if form != nil { | |
| 134 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 135 | + } | |
| 99 | 136 | rec := httptest.NewRecorder() |
| 100 | 137 | r.ServeHTTP(rec, req) |
| 101 | 138 | return rec.Body.String(), rec.Code, rec.Header().Get("Location") |
internal/web/templates/orgs/team_view.htmlmodified@@ -46,7 +46,7 @@ | ||
| 46 | 46 | {{ if $.IsOwner }} |
| 47 | 47 | <form method="POST" action="/{{ $.Org.Slug }}/teams/{{ $.Team.Slug }}/members"> |
| 48 | 48 | <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> |
| 49 | - <input type="hidden" name="username" value="{{ .Username }}"> | |
| 49 | + <input type="hidden" name="user_id" value="{{ .UserID }}"> | |
| 50 | 50 | <input type="hidden" name="action" value="remove"> |
| 51 | 51 | <button type="submit" class="shithub-button shithub-button-danger">Remove</button> |
| 52 | 52 | </form> |
@@ -129,9 +129,15 @@ | ||
| 129 | 129 | {{ if .IsOwner }} |
| 130 | 130 | <section class="shithub-org-team-manage-box"> |
| 131 | 131 | <h2>Add member</h2> |
| 132 | + {{ if .MemberCandidates }} | |
| 132 | 133 | <form method="POST" action="/{{ .Org.Slug }}/teams/{{ .Team.Slug }}/members"> |
| 133 | 134 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 134 | - <label><span>Username</span><input type="text" name="username" required placeholder="@username"></label> | |
| 135 | + <label><span>Organization member</span> | |
| 136 | + <select name="user_id" required> | |
| 137 | + <option value="">Select member</option> | |
| 138 | + {{ range .MemberCandidates }}<option value="{{ .ID }}">{{ if .DisplayName }}{{ .DisplayName }} · {{ end }}@{{ .Username }}</option>{{ end }} | |
| 139 | + </select> | |
| 140 | + </label> | |
| 135 | 141 | <label><span>Role</span> |
| 136 | 142 | <select name="role"> |
| 137 | 143 | <option value="member" selected>Member</option> |
@@ -140,6 +146,9 @@ | ||
| 140 | 146 | </label> |
| 141 | 147 | <button type="submit" class="shithub-button shithub-button-primary">Add member</button> |
| 142 | 148 | </form> |
| 149 | + {{ else }} | |
| 150 | + <p class="shithub-muted">All organization members are already on this team.</p> | |
| 151 | + {{ end }} | |
| 143 | 152 | </section> |
| 144 | 153 | |
| 145 | 154 | <section class="shithub-org-team-manage-box"> |