-
Notifications
You must be signed in to change notification settings - Fork 63
Add pscale org member commands #1355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ec6dd00
Add pscale org member list, show, update, and remove
no-itsbackpack aeddccb
Paginate org member list and keep API permission reasons
no-itsbackpack fb47720
Merge branch 'main' into feat/org-members
no-itsbackpack dcf878a
Distinguish an empty member page from an empty organization
no-itsbackpack 11cf42b
Accept email as the primary org member identifier
no-itsbackpack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <command>", | ||
| 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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <email|user-id>", | ||
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <email|user-id>", | ||
| 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 | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.