Skip to content

Commit d0fc81a

Browse files
authored
chore: implement cli list organization members (#13555)
example cli command: `coder organization members`
1 parent bbe23ed commit d0fc81a

File tree

10 files changed

+317
-45
lines changed

10 files changed

+317
-45
lines changed

cli/cliui/table.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,24 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
205205
}
206206
}
207207

208+
// Guard against nil dereferences
209+
if v != nil {
210+
rt := reflect.TypeOf(v)
211+
switch rt.Kind() {
212+
case reflect.Slice:
213+
// By default, the behavior is '%v', which just returns a string like
214+
// '[a b c]'. This will add commas in between each value.
215+
strs := make([]string, 0)
216+
vt := reflect.ValueOf(v)
217+
for i := 0; i < vt.Len(); i++ {
218+
strs = append(strs, fmt.Sprintf("%v", vt.Index(i).Interface()))
219+
}
220+
v = "[" + strings.Join(strs, ", ") + "]"
221+
default:
222+
// Leave it as it is
223+
}
224+
}
225+
208226
rowSlice[i] = v
209227
}
210228

cli/cliui/table_test.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ func Test_DisplayTable(t *testing.T) {
138138
t.Parallel()
139139

140140
expected := `
141-
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
142-
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
143-
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
144-
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
141+
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
142+
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
143+
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
144+
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
145145
`
146146

147147
// Test with non-pointer values.
@@ -165,10 +165,10 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
165165
t.Parallel()
166166

167167
expected := `
168-
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
169-
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
170-
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
171-
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
168+
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
169+
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
170+
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
171+
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
172172
`
173173

174174
out, err := cliui.DisplayTable(in, "age", nil)
@@ -235,12 +235,12 @@ Alice 25
235235
t.Run("WithSeparator", func(t *testing.T) {
236236
t.Parallel()
237237
expected := `
238-
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
239-
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
240-
-------------------------------------------------------------------------------------------------------------------------------------------------------------
241-
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
242-
-------------------------------------------------------------------------------------------------------------------------------------------------------------
243-
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
238+
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
239+
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
240+
---------------------------------------------------------------------------------------------------------------------------------------------------------------
241+
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
242+
---------------------------------------------------------------------------------------------------------------------------------------------------------------
243+
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
244244
`
245245

246246
var inlineIn []any

cli/organization.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ import (
1818

1919
func (r *RootCmd) organizations() *serpent.Command {
2020
cmd := &serpent.Command{
21-
Annotations: workspaceCommand,
22-
Use: "organizations [subcommand]",
23-
Short: "Organization related commands",
24-
Aliases: []string{"organization", "org", "orgs"},
25-
Hidden: true, // Hidden until these commands are complete.
21+
Use: "organizations [subcommand]",
22+
Short: "Organization related commands",
23+
Aliases: []string{"organization", "org", "orgs"},
24+
Hidden: true, // Hidden until these commands are complete.
2625
Handler: func(inv *serpent.Invocation) error {
2726
return inv.Command.HelpHandler(inv)
2827
},
@@ -31,6 +30,7 @@ func (r *RootCmd) organizations() *serpent.Command {
3130
r.switchOrganization(),
3231
r.createOrganization(),
3332
r.organizationRoles(),
33+
r.organizationMembers(),
3434
},
3535
}
3636

cli/organizationmembers.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"golang.org/x/xerrors"
7+
8+
"github.com/coder/coder/v2/cli/cliui"
9+
"github.com/coder/coder/v2/codersdk"
10+
"github.com/coder/serpent"
11+
)
12+
13+
func (r *RootCmd) organizationMembers() *serpent.Command {
14+
formatter := cliui.NewOutputFormatter(
15+
cliui.TableFormat([]codersdk.OrganizationMemberWithName{}, []string{"username", "organization_roles"}),
16+
cliui.JSONFormat(),
17+
)
18+
19+
client := new(codersdk.Client)
20+
cmd := &serpent.Command{
21+
Use: "members",
22+
Short: "List all organization members",
23+
Aliases: []string{"member"},
24+
Middleware: serpent.Chain(
25+
serpent.RequireNArgs(0),
26+
r.InitClient(client),
27+
),
28+
Handler: func(inv *serpent.Invocation) error {
29+
ctx := inv.Context()
30+
organization, err := CurrentOrganization(r, inv, client)
31+
if err != nil {
32+
return err
33+
}
34+
35+
res, err := client.OrganizationMembers(ctx, organization.ID)
36+
if err != nil {
37+
return xerrors.Errorf("fetch members: %w", err)
38+
}
39+
40+
out, err := formatter.Format(inv.Context(), res)
41+
if err != nil {
42+
return err
43+
}
44+
45+
_, err = fmt.Fprintln(inv.Stdout, out)
46+
return err
47+
},
48+
}
49+
formatter.AttachOptions(&cmd.Options)
50+
51+
return cmd
52+
}

cli/organizationmembers_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/cli/clitest"
10+
"github.com/coder/coder/v2/coderd/coderdtest"
11+
"github.com/coder/coder/v2/coderd/rbac"
12+
"github.com/coder/coder/v2/testutil"
13+
)
14+
15+
func TestListOrganizationMembers(t *testing.T) {
16+
t.Parallel()
17+
18+
t.Run("OK", func(t *testing.T) {
19+
t.Parallel()
20+
21+
ownerClient := coderdtest.New(t, &coderdtest.Options{})
22+
owner := coderdtest.CreateFirstUser(t, ownerClient)
23+
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
24+
25+
ctx := testutil.Context(t, testutil.WaitMedium)
26+
inv, root := clitest.New(t, "organization", "members", "-c", "user_id,username,roles")
27+
clitest.SetupConfig(t, client, root)
28+
29+
buf := new(bytes.Buffer)
30+
inv.Stdout = buf
31+
err := inv.WithContext(ctx).Run()
32+
require.NoError(t, err)
33+
require.Contains(t, buf.String(), user.Username)
34+
require.Contains(t, buf.String(), owner.UserID.String())
35+
})
36+
}

coderd/members.go

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
package coderd
22

33
import (
4+
"context"
45
"net/http"
56

67
"github.com/google/uuid"
7-
8-
"github.com/coder/coder/v2/coderd/database/db2sdk"
9-
"github.com/coder/coder/v2/coderd/rbac"
8+
"golang.org/x/xerrors"
109

1110
"github.com/coder/coder/v2/coderd/database"
11+
"github.com/coder/coder/v2/coderd/database/db2sdk"
1212
"github.com/coder/coder/v2/coderd/httpapi"
1313
"github.com/coder/coder/v2/coderd/httpmw"
14+
"github.com/coder/coder/v2/coderd/rbac"
1415
"github.com/coder/coder/v2/codersdk"
1516
)
1617

@@ -41,7 +42,13 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) {
4142
return
4243
}
4344

44-
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(members, convertOrganizationMemberRow))
45+
resp, err := convertOrganizationMemberRows(ctx, api.Database, members)
46+
if err != nil {
47+
httpapi.InternalServerError(rw, err)
48+
return
49+
}
50+
51+
httpapi.Write(ctx, rw, http.StatusOK, resp)
4552
}
4653

4754
// @Summary Assign role to organization member
@@ -87,30 +94,101 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
8794
return
8895
}
8996

90-
httpapi.Write(ctx, rw, http.StatusOK, convertOrganizationMember(updatedUser))
97+
resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{updatedUser})
98+
if err != nil {
99+
httpapi.InternalServerError(rw, err)
100+
return
101+
}
102+
if len(resp) != 1 {
103+
httpapi.InternalServerError(rw, xerrors.Errorf("failed to serialize member to response, update still succeeded"))
104+
return
105+
}
106+
httpapi.Write(ctx, rw, http.StatusOK, resp[0])
91107
}
92108

93-
func convertOrganizationMember(mem database.OrganizationMember) codersdk.OrganizationMember {
94-
convertedMember := codersdk.OrganizationMember{
95-
UserID: mem.UserID,
96-
OrganizationID: mem.OrganizationID,
97-
CreatedAt: mem.CreatedAt,
98-
UpdatedAt: mem.UpdatedAt,
99-
Roles: make([]codersdk.SlimRole, 0, len(mem.Roles)),
109+
// convertOrganizationMembers batches the role lookup to make only 1 sql call
110+
// We
111+
func convertOrganizationMembers(ctx context.Context, db database.Store, mems []database.OrganizationMember) ([]codersdk.OrganizationMember, error) {
112+
converted := make([]codersdk.OrganizationMember, 0, len(mems))
113+
roleLookup := make([]database.NameOrganizationPair, 0)
114+
115+
for _, m := range mems {
116+
converted = append(converted, codersdk.OrganizationMember{
117+
UserID: m.UserID,
118+
OrganizationID: m.OrganizationID,
119+
CreatedAt: m.CreatedAt,
120+
UpdatedAt: m.UpdatedAt,
121+
Roles: db2sdk.List(m.Roles, func(r string) codersdk.SlimRole {
122+
// If it is a built-in role, no lookups are needed.
123+
rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID})
124+
if err == nil {
125+
return db2sdk.SlimRole(rbacRole)
126+
}
127+
128+
// We know the role name and the organization ID. We are missing the
129+
// display name. Append the lookup parameter, so we can get the display name
130+
roleLookup = append(roleLookup, database.NameOrganizationPair{
131+
Name: r,
132+
OrganizationID: m.OrganizationID,
133+
})
134+
return codersdk.SlimRole{
135+
Name: r,
136+
DisplayName: "",
137+
OrganizationID: m.OrganizationID.String(),
138+
}
139+
}),
140+
})
141+
}
142+
143+
customRoles, err := db.CustomRoles(ctx, database.CustomRolesParams{
144+
LookupRoles: roleLookup,
145+
ExcludeOrgRoles: false,
146+
OrganizationID: uuid.UUID{},
147+
})
148+
if err != nil {
149+
// We are missing the display names, but that is not absolutely required. So just
150+
// return the converted and the names will be used instead of the display names.
151+
return converted, xerrors.Errorf("lookup custom roles: %w", err)
152+
}
153+
154+
// Now map the customRoles back to the slimRoles for their display name.
155+
customRolesMap := make(map[string]database.CustomRole)
156+
for _, role := range customRoles {
157+
customRolesMap[role.RoleIdentifier().UniqueName()] = role
100158
}
101159

102-
for _, roleName := range mem.Roles {
103-
rbacRole, _ := rbac.RoleByName(rbac.RoleIdentifier{Name: roleName, OrganizationID: mem.OrganizationID})
104-
convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole))
160+
for i := range converted {
161+
for j, role := range converted[i].Roles {
162+
if cr, ok := customRolesMap[role.UniqueName()]; ok {
163+
converted[i].Roles[j].DisplayName = cr.DisplayName
164+
}
165+
}
105166
}
106-
return convertedMember
167+
168+
return converted, nil
107169
}
108170

109-
func convertOrganizationMemberRow(row database.OrganizationMembersRow) codersdk.OrganizationMemberWithName {
110-
convertedMember := codersdk.OrganizationMemberWithName{
111-
Username: row.Username,
112-
OrganizationMember: convertOrganizationMember(row.OrganizationMember),
171+
func convertOrganizationMemberRows(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithName, error) {
172+
members := make([]database.OrganizationMember, 0)
173+
for _, row := range rows {
174+
members = append(members, row.OrganizationMember)
175+
}
176+
177+
convertedMembers, err := convertOrganizationMembers(ctx, db, members)
178+
if err != nil {
179+
return nil, err
180+
}
181+
if len(convertedMembers) != len(rows) {
182+
return nil, xerrors.Errorf("conversion failed, mismatch slice lengths")
183+
}
184+
185+
converted := make([]codersdk.OrganizationMemberWithName, 0)
186+
for i := range convertedMembers {
187+
converted = append(converted, codersdk.OrganizationMemberWithName{
188+
Username: rows[i].Username,
189+
OrganizationMember: convertedMembers[i],
190+
})
113191
}
114192

115-
return convertedMember
193+
return converted, nil
116194
}

coderd/rbac/roles.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ func (r RoleIdentifier) String() string {
9696
return r.Name
9797
}
9898

99+
func (r RoleIdentifier) UniqueName() string {
100+
return r.String()
101+
}
102+
99103
func (r *RoleIdentifier) MarshalJSON() ([]byte, error) {
100104
return json.Marshal(r.String())
101105
}

codersdk/organizations.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ type Organization struct {
5151
}
5252

5353
type OrganizationMember struct {
54-
UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"`
55-
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"`
56-
CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"`
57-
UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"`
58-
Roles []SlimRole `db:"roles" json:"roles"`
54+
UserID uuid.UUID `table:"user id" json:"user_id" format:"uuid"`
55+
OrganizationID uuid.UUID `table:"organization id" json:"organization_id" format:"uuid"`
56+
CreatedAt time.Time `table:"created at" json:"created_at" format:"date-time"`
57+
UpdatedAt time.Time `table:"updated at" json:"updated_at" format:"date-time"`
58+
Roles []SlimRole `table:"organization_roles" json:"roles"`
5959
}
6060

6161
type OrganizationMemberWithName struct {

codersdk/roles.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ type SlimRole struct {
1919
OrganizationID string `json:"organization_id,omitempty"`
2020
}
2121

22+
func (s SlimRole) String() string {
23+
if s.DisplayName != "" {
24+
return s.DisplayName
25+
}
26+
return s.Name
27+
}
28+
29+
// UniqueName concatenates the organization ID to create a globally unique
30+
// string name for the role.
31+
func (s SlimRole) UniqueName() string {
32+
if s.OrganizationID != "" {
33+
return s.Name + ":" + s.OrganizationID
34+
}
35+
return s.Name
36+
}
37+
2238
type AssignableRoles struct {
2339
Role `table:"r,recursive_inline"`
2440
Assignable bool `json:"assignable" table:"assignable"`

0 commit comments

Comments
 (0)