diff --git a/AGENTS.md b/AGENTS.md index 7b41345e..6ebdbe07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,17 @@ pscale --org database list --format json Pass `--org ` on resource commands (`database`, `branch`, `sql`, `api`, …). Not on `org list`. + Organization members (email or the USER_ID from list): + + ```bash + pscale org member list --org --format json + pscale org member show user@example.com --org --format json + pscale org member update user@example.com --org --format json --role member + pscale org member remove user@example.com --org --format json --force + ``` + + Only org admins can change another member's role or remove someone else. Nobody can change their own role. `--role` is `admin`, `member`, or `analyst`. + 5. **Discover resources** before SQL: ```bash diff --git a/internal/cmd/org/member.go b/internal/cmd/org/member.go new file mode 100644 index 00000000..2cddde94 --- /dev/null +++ b/internal/cmd/org/member.go @@ -0,0 +1,126 @@ +package org + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "member ", + Short: "List, show, update, and remove organization members", + Long: `Manage organization members and their roles. + +show, update, and remove take an email or a user id (the USER_ID column from +'org member list'). Email is usually the easiest. + +Only organization admins can change another member's role or remove someone +else. Nobody can change their own role. Members can still leave the organization +themselves.`, + } + + cmd.PersistentFlags().StringVar(&ch.Config.Organization, "org", ch.Config.Organization, "The organization for the current user") + cmd.MarkPersistentFlagRequired("org") + + cmd.AddCommand(MemberListCmd(ch)) + cmd.AddCommand(MemberShowCmd(ch)) + cmd.AddCommand(MemberUpdateCmd(ch)) + cmd.AddCommand(MemberRemoveCmd(ch)) + + return cmd +} + +type organizationMember struct { + UserID string `header:"user_id" json:"user_id"` + Name string `header:"name" json:"name"` + Email string `header:"email" json:"email"` + Role string `header:"role" json:"role"` + + orig *ps.OrganizationMembership +} + +func toOrganizationMembers(members []*ps.OrganizationMembership) []*organizationMember { + out := make([]*organizationMember, 0, len(members)) + for _, m := range members { + out = append(out, toOrganizationMember(m)) + } + return out +} + +func toOrganizationMember(m *ps.OrganizationMembership) *organizationMember { + name := m.User.DisplayName + if name == "" { + name = m.User.Name + } + return &organizationMember{ + UserID: m.User.ID, + Name: name, + Email: m.User.Email, + Role: m.Role, + orig: m, + } +} + +func (m *organizationMember) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(m.orig, "", " ") +} + +func (m *organizationMember) MarshalCSVValue() interface{} { + return []*organizationMember{m} +} + +func memberNotFound(org, id string) error { + return fmt.Errorf("member %s does not exist in organization %s", + printer.BoldBlue(id), printer.BoldBlue(org)) +} + +func matchMember(m *ps.OrganizationMembership, id string) bool { + if m.User.ID == id || m.ID == id { + return true + } + return strings.EqualFold(m.User.Email, id) +} + +func resolveMember(ctx context.Context, ch *cmdutil.Helper, client *ps.Client, id string) (*ps.OrganizationMembership, error) { + org := ch.Config.Organization + query := "" + if strings.Contains(id, "@") { + query = id + } + + page := 1 + perPage := 100 + for { + members, err := client.Organizations.ListMembers(ctx, &ps.ListOrganizationMembersRequest{ + Organization: org, + Query: query, + }, ps.WithPage(page), ps.WithPerPage(perPage)) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return nil, fmt.Errorf("organization %s does not exist", printer.BoldBlue(org)) + default: + return nil, cmdutil.HandleError(err) + } + } + + for _, m := range members { + if matchMember(m, id) { + return m, nil + } + } + if len(members) < perPage { + break + } + page++ + } + + return nil, memberNotFound(org, id) +} diff --git a/internal/cmd/org/member_list.go b/internal/cmd/org/member_list.go new file mode 100644 index 00000000..bf71736b --- /dev/null +++ b/internal/cmd/org/member_list.go @@ -0,0 +1,73 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberListCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + query string + page int + perPage int + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List members of an organization", + Long: `List members of an organization. + +Results are paginated: 100 members per page by default. Use --page and +--per-page to walk organizations with more members than one page holds.`, + Args: cobra.NoArgs, + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + org := ch.Config.Organization + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching members of %s...", printer.BoldBlue(org))) + defer end() + + members, err := client.Organizations.ListMembers(ctx, &ps.ListOrganizationMembersRequest{ + Organization: org, + Query: flags.query, + }, ps.WithPage(flags.page), ps.WithPerPage(flags.perPage)) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("organization %s does not exist", printer.BoldBlue(org)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(members) == 0 && ch.Printer.Format() == printer.Human { + if flags.page > 0 { + ch.Printer.Println("No members found on this page.") + } else if flags.query != "" { + ch.Printer.Printf("No members in %s match %s.\n", printer.BoldBlue(org), printer.BoldBlue(flags.query)) + } else { + ch.Printer.Printf("No members in %s.\n", printer.BoldBlue(org)) + } + return nil + } + + return ch.Printer.PrintResource(toOrganizationMembers(members)) + }, + } + + cmd.Flags().StringVar(&flags.query, "query", "", "Filter members by name or email prefix") + cmd.Flags().IntVar(&flags.page, "page", 0, "Page number to fetch") + cmd.Flags().IntVar(&flags.perPage, "per-page", 100, "Number of results per page") + return cmd +} diff --git a/internal/cmd/org/member_remove.go b/internal/cmd/org/member_remove.go new file mode 100644 index 00000000..cb3125b1 --- /dev/null +++ b/internal/cmd/org/member_remove.go @@ -0,0 +1,93 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberRemoveCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + force bool + deletePasswords bool + deleteServiceTokens bool + } + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a member from an organization", + Aliases: []string{"rm"}, + Long: `Remove a member from an organization. + +Identify the member by email or by the USER_ID from 'org member list'. +Removing someone else requires organization admin. You can remove yourself +(leave) without being an admin. The last admin cannot be removed.`, + Args: cmdutil.RequiredArgs("email|user-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + id := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching member %s in %s...", printer.BoldBlue(id), printer.BoldBlue(ch.Config.Organization))) + member, err := resolveMember(ctx, ch, client, id) + end() + if err != nil { + return err + } + + if !flags.force { + if err := ch.Printer.ConfirmCommand(member.User.Email, "remove organization member", "removal of organization member"); err != nil { + return err + } + } + + end = ch.Printer.PrintProgress(fmt.Sprintf("Removing %s from %s...", printer.BoldBlue(member.User.Email), printer.BoldBlue(ch.Config.Organization))) + defer end() + + err = client.Organizations.RemoveMember(ctx, &ps.RemoveOrganizationMemberRequest{ + Organization: ch.Config.Organization, + UserID: member.User.ID, + DeletePasswords: flags.deletePasswords, + DeleteServiceTokens: flags.deleteServiceTokens, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return memberNotFound(ch.Config.Organization, id) + case ps.ErrPermission: + // More than one server-side rule can reject this, so surface the + // API's reason rather than assuming the caller is not an admin. + return fmt.Errorf("cannot remove %s: %w", member.User.Email, err) + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Removed %s from %s.\n", + printer.BoldBlue(member.User.Email), + printer.BoldBlue(ch.Config.Organization)) + return nil + } + + return ch.Printer.PrintResource(map[string]string{ + "result": "member removed", + "org": ch.Config.Organization, + "user": member.User.ID, + }) + }, + } + + cmd.Flags().BoolVar(&flags.force, "force", false, "Remove the member without confirmation") + cmd.Flags().BoolVar(&flags.deletePasswords, "delete-passwords", false, "Delete passwords created by the member. Cannot be used when removing yourself.") + cmd.Flags().BoolVar(&flags.deleteServiceTokens, "delete-service-tokens", false, "Delete service tokens created by the member. Cannot be used when removing yourself.") + return cmd +} diff --git a/internal/cmd/org/member_show.go b/internal/cmd/org/member_show.go new file mode 100644 index 00000000..ad2318ed --- /dev/null +++ b/internal/cmd/org/member_show.go @@ -0,0 +1,52 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberShowCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show an organization member", + Long: `Show an organization member by email or user id. + +'org member list' prints both EMAIL and USER_ID.`, + Args: cmdutil.RequiredArgs("email|user-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + id := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching member %s in %s...", printer.BoldBlue(id), printer.BoldBlue(ch.Config.Organization))) + defer end() + + member, err := client.Organizations.GetMember(ctx, &ps.GetOrganizationMemberRequest{ + Organization: ch.Config.Organization, + UserID: id, + }) + if err != nil { + if cmdutil.ErrCode(err) != ps.ErrNotFound { + return cmdutil.HandleError(err) + } + member, err = resolveMember(ctx, ch, client, id) + if err != nil { + return err + } + } + end() + + return ch.Printer.PrintResource(toOrganizationMember(member)) + }, + } + + return cmd +} diff --git a/internal/cmd/org/member_test.go b/internal/cmd/org/member_test.go new file mode 100644 index 00000000..fee33ae8 --- /dev/null +++ b/internal/cmd/org/member_test.go @@ -0,0 +1,290 @@ +package org + +import ( + "bytes" + "context" + "errors" + "net/url" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +func testMember() *ps.OrganizationMembership { + return &ps.OrganizationMembership{ + ID: "mem-1", + Role: "member", + User: ps.OrganizationMemberUser{ + ID: "user-1", + Name: "Ada", + DisplayName: "Ada Lovelace", + Email: "ada@example.com", + }, + } +} + +func TestOrg_MemberListCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + c.Assert(req.Organization, qt.Equals, "planetscale") + c.Assert(req.Query, qt.Equals, "ada") + return []*ps.OrganizationMembership{testMember()}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberListCmd(ch) + cmd.SetArgs([]string{"--query", "ada"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "ada@example.com") +} + +func TestOrg_MemberListCmd_Pagination(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + listOpts := &ps.ListOptions{URLValues: &url.Values{}} + for _, opt := range opts { + c.Assert(opt(listOpts), qt.IsNil) + } + c.Assert(listOpts.URLValues.Get("page"), qt.Equals, "3") + c.Assert(listOpts.URLValues.Get("per_page"), qt.Equals, "25") + return []*ps.OrganizationMembership{testMember()}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberListCmd(ch) + cmd.SetArgs([]string{"--page", "3", "--per-page", "25"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) +} + +func TestOrg_MemberListCmd_EmptyPage(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return nil, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberListCmd(ch) + cmd.SetArgs([]string{"--page", "4"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(buf.String(), qt.Contains, "No members found on this page.") + c.Assert(buf.String(), qt.Not(qt.Contains), "No members in") +} + +func TestOrg_MemberUpdateCmd_PermissionErrorKeepsServerReason(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + apiErr := &ps.Error{Code: ps.ErrPermission} + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return []*ps.OrganizationMembership{testMember()}, nil + }, + UpdateMemberFn: func(ctx context.Context, req *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + return nil, apiErr + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberUpdateCmd(ch) + cmd.SetArgs([]string{"user-1", "--role", "admin"}) + err := cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(errors.Is(err, apiErr), qt.IsTrue) + c.Assert(err.Error(), qt.Contains, "ada@example.com") +} + +func TestOrg_MemberShowCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + GetMemberFn: func(ctx context.Context, req *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + c.Assert(req.UserID, qt.Equals, "user-1") + return testMember(), nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberShowCmd(ch) + cmd.SetArgs([]string{"user-1"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetMemberFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "user-1") +} + +func TestOrg_MemberShowCmd_ResolveEmail(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + GetMemberFn: func(ctx context.Context, req *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + return nil, &ps.Error{Code: ps.ErrNotFound} + }, + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + c.Assert(req.Query, qt.Equals, "ada@example.com") + return []*ps.OrganizationMembership{testMember()}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberShowCmd(ch) + cmd.SetArgs([]string{"ada@example.com"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "ada@example.com") +} + +func TestOrg_MemberUpdateCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return []*ps.OrganizationMembership{testMember()}, nil + }, + UpdateMemberFn: func(ctx context.Context, req *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + c.Assert(req.UserID, qt.Equals, "user-1") + c.Assert(req.Role, qt.Equals, "admin") + m := testMember() + m.Role = "admin" + return m, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberUpdateCmd(ch) + cmd.SetArgs([]string{"user-1", "--role", "admin"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.UpdateMemberFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, `"role": "admin"`) +} + +func TestOrg_MemberRemoveCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return []*ps.OrganizationMembership{testMember()}, nil + }, + RemoveMemberFn: func(ctx context.Context, req *ps.RemoveOrganizationMemberRequest) error { + c.Assert(req.UserID, qt.Equals, "user-1") + c.Assert(req.DeletePasswords, qt.IsTrue) + return nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberRemoveCmd(ch) + cmd.SetArgs([]string{"user-1", "--force", "--delete-passwords"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.RemoveMemberFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "member removed") +} diff --git a/internal/cmd/org/member_update.go b/internal/cmd/org/member_update.go new file mode 100644 index 00000000..8cc89b4e --- /dev/null +++ b/internal/cmd/org/member_update.go @@ -0,0 +1,85 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberUpdateCmd(ch *cmdutil.Helper) *cobra.Command { + var role string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an organization member's role", + Long: `Update another member's organization role. + +Identify the member by email or by the USER_ID from 'org member list'. +Only organization admins can do this. You cannot change your own role. +Assignable roles: admin, member, analyst.`, + Args: cmdutil.RequiredArgs("email|user-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + id := args[0] + + if role == "" { + return fmt.Errorf("must specify --role (admin, member, or analyst)") + } + switch role { + case "admin", "member", "analyst": + default: + return fmt.Errorf("--role accepts admin, member, or analyst, got %q", role) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching member %s in %s...", printer.BoldBlue(id), printer.BoldBlue(ch.Config.Organization))) + member, err := resolveMember(ctx, ch, client, id) + end() + if err != nil { + return err + } + + end = ch.Printer.PrintProgress(fmt.Sprintf("Updating role for %s to %s...", printer.BoldBlue(member.User.Email), printer.BoldBlue(role))) + defer end() + + updated, err := client.Organizations.UpdateMember(ctx, &ps.UpdateOrganizationMemberRequest{ + Organization: ch.Config.Organization, + UserID: member.User.ID, + Role: role, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return memberNotFound(ch.Config.Organization, id) + case ps.ErrPermission: + // More than one server-side rule can reject this, so surface the + // API's reason rather than assuming the caller is not an admin. + return fmt.Errorf("cannot change the role for %s: %w", member.User.Email, err) + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Updated %s to %s in %s.\n", + printer.BoldBlue(updated.User.Email), + printer.BoldBlue(updated.Role), + printer.BoldBlue(ch.Config.Organization)) + return nil + } + + return ch.Printer.PrintResource(toOrganizationMember(updated)) + }, + } + + cmd.Flags().StringVar(&role, "role", "", "Role to assign: admin, member, or analyst") + return cmd +} diff --git a/internal/cmd/org/org.go b/internal/cmd/org/org.go index df0fabf7..fc9ac523 100644 --- a/internal/cmd/org/org.go +++ b/internal/cmd/org/org.go @@ -18,6 +18,7 @@ func OrgCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(SwitchCmd(ch)) cmd.AddCommand(ShowCmd(ch)) cmd.AddCommand(ListCmd(ch)) + cmd.AddCommand(MemberCmd(ch)) return cmd } diff --git a/internal/mock/org.go b/internal/mock/org.go index 3e86ddfa..525d2545 100644 --- a/internal/mock/org.go +++ b/internal/mock/org.go @@ -18,6 +18,18 @@ type OrganizationsService struct { ListClusterSKUsFn func(context.Context, *ps.ListOrganizationClusterSKUsRequest, ...ps.ListOption) ([]*ps.ClusterSKU, error) ListClusterSKUsFnInvoked bool + + ListMembersFn func(context.Context, *ps.ListOrganizationMembersRequest, ...ps.ListOption) ([]*ps.OrganizationMembership, error) + ListMembersFnInvoked bool + + GetMemberFn func(context.Context, *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) + GetMemberFnInvoked bool + + UpdateMemberFn func(context.Context, *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) + UpdateMemberFnInvoked bool + + RemoveMemberFn func(context.Context, *ps.RemoveOrganizationMemberRequest) error + RemoveMemberFnInvoked bool } func (o *OrganizationsService) Get(ctx context.Context, req *ps.GetOrganizationRequest) (*ps.Organization, error) { @@ -39,3 +51,23 @@ func (o *OrganizationsService) ListClusterSKUs(ctx context.Context, req *ps.List o.ListClusterSKUsFnInvoked = true return o.ListClusterSKUsFn(ctx, req, opts...) } + +func (o *OrganizationsService) ListMembers(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + o.ListMembersFnInvoked = true + return o.ListMembersFn(ctx, req, opts...) +} + +func (o *OrganizationsService) GetMember(ctx context.Context, req *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + o.GetMemberFnInvoked = true + return o.GetMemberFn(ctx, req) +} + +func (o *OrganizationsService) UpdateMember(ctx context.Context, req *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + o.UpdateMemberFnInvoked = true + return o.UpdateMemberFn(ctx, req) +} + +func (o *OrganizationsService) RemoveMember(ctx context.Context, req *ps.RemoveOrganizationMemberRequest) error { + o.RemoveMemberFnInvoked = true + return o.RemoveMemberFn(ctx, req) +} diff --git a/internal/planetscale/organization_members.go b/internal/planetscale/organization_members.go new file mode 100644 index 00000000..8fe2d56d --- /dev/null +++ b/internal/planetscale/organization_members.go @@ -0,0 +1,141 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +// OrganizationMembership is a user's membership in an organization. +// ID is the membership public id. PATCH/DELETE/GET member routes take the +// nested user's public id (User.ID), not this ID. +type OrganizationMembership struct { + ID string `json:"id"` + Role string `json:"role"` + User OrganizationMemberUser `json:"user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OrganizationMemberUser is the user nested on an organization membership. +type OrganizationMemberUser struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + TwoFactorAuthConfigured bool `json:"two_factor_auth_configured"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type organizationMembersResponse struct { + Data []*OrganizationMembership `json:"data"` +} + +type ListOrganizationMembersRequest struct { + Organization string + Query string +} + +type GetOrganizationMemberRequest struct { + Organization string + UserID string +} + +type UpdateOrganizationMemberRequest struct { + Organization string `json:"-"` + UserID string `json:"-"` + Role string `json:"role"` +} + +type RemoveOrganizationMemberRequest struct { + Organization string + UserID string + DeletePasswords bool + DeleteServiceTokens bool +} + +func organizationMembersAPIPath(org string) string { + return path.Join(organizationsAPIPath, org, "members") +} + +func organizationMemberAPIPath(org, userID string) string { + return path.Join(organizationMembersAPIPath(org), userID) +} + +func (o *organizationsService) ListMembers(ctx context.Context, listReq *ListOrganizationMembersRequest, opts ...ListOption) ([]*OrganizationMembership, error) { + defaultOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + if err := opt(defaultOpts); err != nil { + return nil, err + } + } + if listReq.Query != "" { + defaultOpts.URLValues.Set("q", listReq.Query) + } + + req, err := o.client.newRequest(http.MethodGet, organizationMembersAPIPath(listReq.Organization), nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list organization members: %w", err) + } + + resp := &organizationMembersResponse{} + if err := o.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (o *organizationsService) GetMember(ctx context.Context, getReq *GetOrganizationMemberRequest) (*OrganizationMembership, error) { + req, err := o.client.newRequest(http.MethodGet, organizationMemberAPIPath(getReq.Organization, getReq.UserID), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get organization member: %w", err) + } + + membership := &OrganizationMembership{} + if err := o.client.do(ctx, req, &membership); err != nil { + return nil, err + } + + return membership, nil +} + +func (o *organizationsService) UpdateMember(ctx context.Context, updateReq *UpdateOrganizationMemberRequest) (*OrganizationMembership, error) { + body := struct { + Role string `json:"role"` + }{Role: updateReq.Role} + + req, err := o.client.newRequest(http.MethodPatch, organizationMemberAPIPath(updateReq.Organization, updateReq.UserID), body) + if err != nil { + return nil, fmt.Errorf("error creating request for update organization member: %w", err) + } + + membership := &OrganizationMembership{} + if err := o.client.do(ctx, req, &membership); err != nil { + return nil, err + } + + return membership, nil +} + +func (o *organizationsService) RemoveMember(ctx context.Context, removeReq *RemoveOrganizationMemberRequest) error { + v := url.Values{} + if removeReq.DeletePasswords { + v.Set("delete_passwords", "true") + } + if removeReq.DeleteServiceTokens { + v.Set("delete_service_tokens", "true") + } + + req, err := o.client.newRequest(http.MethodDelete, organizationMemberAPIPath(removeReq.Organization, removeReq.UserID), nil, WithQueryParams(v)) + if err != nil { + return fmt.Errorf("error creating request for remove organization member: %w", err) + } + + return o.client.do(ctx, req, nil) +} diff --git a/internal/planetscale/organization_members_test.go b/internal/planetscale/organization_members_test.go new file mode 100644 index 00000000..8a971189 --- /dev/null +++ b/internal/planetscale/organization_members_test.go @@ -0,0 +1,90 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestOrganizations_ListMembers(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/members") + c.Assert(r.URL.Query().Get("q"), qt.Equals, "ada") + w.WriteHeader(200) + out := `{"data":[{"id":"mem-1","role":"admin","user":{"id":"user-1","name":"Ada","email":"ada@example.com","display_name":"Ada","avatar_url":"","two_factor_auth_configured":true,"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + members, err := client.Organizations.ListMembers(context.Background(), &ListOrganizationMembersRequest{ + Organization: "my-org", + Query: "ada", + }) + c.Assert(err, qt.IsNil) + c.Assert(members, qt.HasLen, 1) + c.Assert(members[0].Role, qt.Equals, "admin") + c.Assert(members[0].User.ID, qt.Equals, "user-1") + c.Assert(members[0].User.Email, qt.Equals, "ada@example.com") +} + +func TestOrganizations_UpdateMember(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/members/user-1") + var body struct { + Role string `json:"role"` + } + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body.Role, qt.Equals, "member") + w.WriteHeader(200) + out := `{"id":"mem-1","role":"member","user":{"id":"user-1","name":"Ada","email":"ada@example.com","display_name":"Ada","avatar_url":"","two_factor_auth_configured":false,"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + member, err := client.Organizations.UpdateMember(context.Background(), &UpdateOrganizationMemberRequest{ + Organization: "my-org", + UserID: "user-1", + Role: "member", + }) + c.Assert(err, qt.IsNil) + c.Assert(member.Role, qt.Equals, "member") + c.Assert(member.UpdatedAt, qt.Equals, time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC)) +} + +func TestOrganizations_RemoveMember(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/members/user-1") + c.Assert(r.URL.Query().Get("delete_passwords"), qt.Equals, "true") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.Organizations.RemoveMember(context.Background(), &RemoveOrganizationMemberRequest{ + Organization: "my-org", + UserID: "user-1", + DeletePasswords: true, + }) + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/organizations.go b/internal/planetscale/organizations.go index 30ca044c..8f7a8044 100644 --- a/internal/planetscale/organizations.go +++ b/internal/planetscale/organizations.go @@ -23,6 +23,10 @@ type OrganizationsService interface { List(context.Context) ([]*Organization, error) ListRegions(context.Context, *ListOrganizationRegionsRequest) ([]*Region, error) ListClusterSKUs(context.Context, *ListOrganizationClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) + ListMembers(context.Context, *ListOrganizationMembersRequest, ...ListOption) ([]*OrganizationMembership, error) + GetMember(context.Context, *GetOrganizationMemberRequest) (*OrganizationMembership, error) + UpdateMember(context.Context, *UpdateOrganizationMemberRequest) (*OrganizationMembership, error) + RemoveMember(context.Context, *RemoveOrganizationMemberRequest) error } // ListRegionsRequest encapsulates the request for getting a list of regions for