From 3e48b5cec80b20b07e4209bf1c3af51283b1aaa1 Mon Sep 17 00:00:00 2001 From: Pratik Jadhav Date: Wed, 29 Jul 2026 12:45:27 +0530 Subject: [PATCH 1/2] fix: role and user command revamp for cloud --- cmd/role.go | 79 +++++++++++++++++++++++++++++++----------- cmd/user.go | 65 +++++++++++++++++++++++++--------- pkg/model/role/role.go | 74 ++------------------------------------- 3 files changed, 110 insertions(+), 108 deletions(-) diff --git a/cmd/role.go b/cmd/role.go index 1528612..29962b5 100644 --- a/cmd/role.go +++ b/cmd/role.go @@ -30,10 +30,13 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" + "golang.org/x/exp/slices" ) type RoleResource struct { - Stream string `json:"stream,omitempty"` + Dataset string `json:"dataset,omitempty"` + Tag string `json:"tag,omitempty"` + Stream string `json:"stream,omitempty"` // Legacy response compatibility. } type RoleData struct { @@ -47,9 +50,14 @@ func (user *RoleData) Render() string { s.WriteString(StandardStyleAlt.Render(user.Privilege)) s.WriteString("\n") if user.Resource != nil { - if user.Resource.Stream != "" { - s.WriteString(StandardStyle.Render("Stream: ")) - s.WriteString(StandardStyleAlt.Render(user.Resource.Stream)) + if dataset := roleDataset(*user.Resource); dataset != "" { + s.WriteString(StandardStyle.Render("Dataset: ")) + s.WriteString(StandardStyleAlt.Render(dataset)) + s.WriteString("\n") + } + if user.Resource.Tag != "" { + s.WriteString(StandardStyle.Render("Tag: ")) + s.WriteString(StandardStyleAlt.Render(user.Resource.Tag)) s.WriteString("\n") } } @@ -78,7 +86,7 @@ var AddRoleCmd = &cobra.Command{ return err } - if strings.Contains(strings.Join(roles, " "), name) { + if slices.Contains(roles, name) { fmt.Println("role already exists, please use a different name") return nil } @@ -91,25 +99,19 @@ var AddRoleCmd = &cobra.Command{ m := _m.(role.Model) privilege := m.Selection.Value() - stream := m.Stream.Value() if !m.Success { fmt.Println("aborted by user") return nil } - var putBody io.Reader - if privilege != "none" { - roleData := RoleData{Privilege: privilege} - switch privilege { - case "writer", "ingestor": - roleData.Resource = &RoleResource{Stream: stream} - case "reader": - roleData.Resource = &RoleResource{Stream: stream} - } - roleDataJSON, _ := json.Marshal([]RoleData{roleData}) - putBody = bytes.NewBuffer(roleDataJSON) + roleData := newRoleData(privilege) + roleDataJSON, err := json.Marshal([]RoleData{roleData}) + if err != nil { + cmd.Annotations["errors"] = fmt.Sprintf("Error encoding role: %s", err.Error()) + return err } + var putBody io.Reader = bytes.NewBuffer(roleDataJSON) req, err := client.NewRequest("PUT", "role/"+name, putBody) if err != nil { @@ -157,6 +159,17 @@ var RemoveRoleCmd = &cobra.Command{ name := args[0] client := internalHTTP.DefaultClient(&DefaultProfile) + var roles []string + if err := fetchRoles(&client, &roles); err != nil { + cmd.Annotations["errors"] = fmt.Sprintf("Error fetching roles: %s", err.Error()) + return err + } + if !slices.Contains(roles, name) { + fmt.Println(missingRoleMessage(name, roles)) + cmd.Annotations["errors"] = fmt.Sprintf("role %s does not exist", name) + return nil + } + req, err := client.NewRequest("DELETE", "role/"+name, nil) if err != nil { cmd.Annotations["errors"] = fmt.Sprintf("Error creating delete request: %s", err.Error()) @@ -272,7 +285,7 @@ func printRoleTable(roles []string, roleResponses []struct { roleWidth := lipgloss.Width("ROLE") privilegeWidth := lipgloss.Width("PRIVILEGE") - streamWidth := lipgloss.Width("STREAM") + streamWidth := lipgloss.Width("DATASET") for idx, roleName := range roles { roleW := lipgloss.Width(roleName) @@ -337,7 +350,7 @@ func printRoleTable(roles []string, roleResponses []struct { fmt.Printf("%s%s%s\n", headerStyle.Render(padRight("ROLE", privilegeColumn+1)), headerStyle.Render(padRight("PRIVILEGE", privilegeWidth+5)), - headerStyle.Render("STREAM"), + headerStyle.Render("DATASET"), ) fmt.Printf("%s%s%s\n", ruleStyle.Render(strings.Repeat("─", privilegeColumn+1)), @@ -380,10 +393,34 @@ func printRoleTable(roles []string, roleResponses []struct { } func roleStream(action RoleData) string { - if action.Resource == nil || action.Resource.Stream == "" { + if action.Resource == nil { return "-" } - return action.Resource.Stream + if dataset := roleDataset(*action.Resource); dataset != "" { + return dataset + } + return "-" +} + +func roleDataset(resource RoleResource) string { + if resource.Dataset != "" { + return resource.Dataset + } + return resource.Stream +} + +func newRoleData(privilege string) RoleData { + return RoleData{Privilege: privilege} +} + +func missingRoleMessage(name string, roles []string) string { + if len(roles) == 0 { + return fmt.Sprintf("role %s doesn't exist. No roles are available", name) + } + + roleNames := append([]string(nil), roles...) + slices.Sort(roleNames) + return fmt.Sprintf("role %s doesn't exist. Available role names: %s", name, strings.Join(roleNames, ", ")) } func orDash(value string) string { diff --git a/cmd/user.go b/cmd/user.go index 6da5900..da8582e 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -20,6 +20,8 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "sort" "strings" "sync" "time" @@ -70,6 +72,11 @@ var addUser = &cobra.Command{ }() name := args[0] + if DefaultProfile.Cloud { + fmt.Println(cloudAddUserMessage()) + cmd.Annotations["error"] = "user creation is not supported for cloud profiles" + return nil + } client := internalHTTP.DefaultClient(&DefaultProfile) users, err := fetchUsers(&client) @@ -87,22 +94,24 @@ var addUser = &cobra.Command{ } // fetch all the roles to be applied to this user - rolesToSet := cmd.Flag(roleFlag).Value.String() - rolesToSetArr := strings.Split(rolesToSet, ",") - - // fetch the role names on the server + rolesToSet := strings.TrimSpace(cmd.Flag(roleFlag).Value.String()) var rolesOnServer []string if err := fetchRoles(&client, &rolesOnServer); err != nil { cmd.Annotations["error"] = err.Error() return err } - rolesOnServerArr := strings.Join(rolesOnServer, " ") + if rolesToSet == "" { + fmt.Println(selfHostedAddUserRoleMessage(name, rolesOnServer)) + cmd.Annotations["error"] = "at least one role is required" + return nil + } + rolesToSetArr := strings.Split(rolesToSet, ",") // validate if roles to be applied are actually present on the server for idx, role := range rolesToSetArr { rolesToSetArr[idx] = strings.TrimSpace(role) - if !strings.Contains(rolesOnServerArr, rolesToSetArr[idx]) { - fmt.Printf("role %s doesn't exist, please create a role using pb role add %s\n", rolesToSetArr[idx], rolesToSetArr[idx]) + if !slices.Contains(rolesOnServer, rolesToSetArr[idx]) { + fmt.Printf("role %s doesn't exist. Create it first with `pb role add %s`, or use an existing role with `pb user add %s --role `\n", rolesToSetArr[idx], rolesToSetArr[idx], name) cmd.Annotations["error"] = fmt.Sprintf("role %s doesn't exist", rolesToSetArr[idx]) return nil } @@ -190,7 +199,7 @@ var RemoveUserCmd = &cobra.Command{ var SetUserRoleCmd = &cobra.Command{ Use: "set-role user-name roles", - Short: "Set roles for a user", + Short: "Add roles to a user", Example: " pb user set-role bob admin,developer", PreRunE: func(_ *cobra.Command, args []string) error { if len(args) < 2 { @@ -216,7 +225,7 @@ var SetUserRoleCmd = &cobra.Command{ if !slices.ContainsFunc(users, func(user UserData) bool { return user.ID == name }) { - fmt.Printf("user doesn't exist. Please create the user with `pb user add %s`\n", name) + fmt.Println(missingUserMessage(name, DefaultProfile.Cloud)) cmd.Annotations["error"] = "user does not exist" return nil } @@ -228,21 +237,16 @@ var SetUserRoleCmd = &cobra.Command{ cmd.Annotations["error"] = err.Error() return err } - rolesOnServerArr := strings.Join(rolesOnServer, " ") - for idx, role := range rolesToSetArr { rolesToSetArr[idx] = strings.TrimSpace(role) - if !strings.Contains(rolesOnServerArr, rolesToSetArr[idx]) { + if !slices.Contains(rolesOnServer, rolesToSetArr[idx]) { fmt.Printf("role %s doesn't exist, please create a role using `pb role add %s`\n", rolesToSetArr[idx], rolesToSetArr[idx]) cmd.Annotations["error"] = fmt.Sprintf("role %s doesn't exist", rolesToSetArr[idx]) return nil } } - var putBody io.Reader - putBodyJSON, _ := json.Marshal(rolesToSetArr) - putBody = bytes.NewBuffer([]byte(putBodyJSON)) - req, err := client.NewRequest("PUT", "user/"+name+"/role", putBody) + req, err := newAddUserRolesRequest(&client, name, rolesToSetArr) if err != nil { cmd.Annotations["error"] = err.Error() return err @@ -274,6 +278,35 @@ var SetUserRoleCmd = &cobra.Command{ }, } +func newAddUserRolesRequest(client *internalHTTP.HTTPClient, name string, roles []string) (*http.Request, error) { + body, err := json.Marshal(roles) + if err != nil { + return nil, err + } + return client.NewRequest(http.MethodPatch, "user/"+name+"/role/add", bytes.NewReader(body)) +} + +func missingUserMessage(name string, cloud bool) string { + if cloud { + return fmt.Sprintf("user doesn't exist. Please invite the user from the Parseable Cloud dashboard first, then set the role with `pb user set-role %s `", name) + } + return fmt.Sprintf("user doesn't exist. Please create the user with `pb user add %s`", name) +} + +func cloudAddUserMessage() string { + return "`pb user add` is not available for Parseable Cloud. Please invite the user from the Parseable Cloud dashboard" +} + +func selfHostedAddUserRoleMessage(name string, roles []string) string { + if len(roles) == 0 { + return fmt.Sprintf("a role is required to create user %s. No roles are available. Create one first with `pb role add `, then run `pb user add %s --role `", name, name) + } + + roleNames := append([]string(nil), roles...) + sort.Strings(roleNames) + return fmt.Sprintf("a role is required to create user %s.\nAvailable roles: %s\nAssign one, for example: `pb user add %s --role %s`", name, strings.Join(roleNames, ", "), name, roleNames[0]) +} + var ListUserCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, diff --git a/pkg/model/role/role.go b/pkg/model/role/role.go index fa83047..279a976 100644 --- a/pkg/model/role/role.go +++ b/pkg/model/role/role.go @@ -20,7 +20,6 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/parseablehq/pb/pkg/model/button" @@ -29,11 +28,8 @@ import ( ) var ( - privileges = []string{"none", "admin", "editor", "writer", "reader", "ingestor"} - navigationMapStreamTag = []string{"role", "stream", "tag", "button"} - navigationMapStream = []string{"role", "stream", "button"} - navigationMap = []string{"role", "button"} - navigationMapNone = []string{"role"} + privileges = []string{"admin", "editor", "ingestor", "reader", "writer"} + navigationMap = []string{"role", "button"} ) // Style for role selection widget @@ -55,45 +51,19 @@ type Model struct { focusIndex int navMap *[]string Selection selection.Model - Stream textinput.Model - Tag textinput.Model button button.Model Success bool } -func (m *Model) Valid() bool { - switch m.Selection.Value() { - case "admin", "editor", "none": - return true - case "writer", "reader", "ingestor": - return !strings.Contains(m.Stream.Value(), " ") && m.Stream.Value() != "" - } - return true -} - func (m *Model) FocusSelected() { m.Selection.Blur() m.Selection.FocusStyle = selectionFocusStyle - m.Stream.Blur() - m.Stream.TextStyle = blurredStyle - m.Stream.PromptStyle = blurredStyle - m.Tag.Blur() - m.Tag.TextStyle = blurredStyle - m.Tag.PromptStyle = blurredStyle m.button.Blur() switch (*m.navMap)[m.focusIndex] { case "role": m.Selection.Focus() m.Selection.FocusStyle = selectionFocusStyleAlt - case "stream": - m.Stream.TextStyle = focusedStyle - m.Stream.PromptStyle = focusedStyle - m.Stream.Focus() - case "tag": - m.Tag.TextStyle = focusedStyle - m.Tag.PromptStyle = focusedStyle - m.Tag.Focus() case "button": m.button.Focus() } @@ -107,18 +77,10 @@ func New() Model { button.FocusStyle = focusedStyle button.BlurredStyle = blurredStyle - stream := textinput.New() - stream.Prompt = "stream: " - - tag := textinput.New() - tag.Prompt = "tag: " - m := Model{ focusIndex: 0, - navMap: &navigationMapNone, + navMap: &navigationMap, Selection: selection, - Stream: stream, - Tag: tag, button: button, Success: false, } @@ -138,12 +100,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Success = true return m, tea.Quit case tea.KeyMsg: - // special cases for enter key if msg.Type == tea.KeyEnter { - if m.Selection.Value() == "none" { - m.Success = true - return m, tea.Quit - } if m.button.Focused() && !m.button.Invalid { m.button, cmd = m.button.Update(msg) return m, cmd @@ -169,26 +126,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch (*m.navMap)[m.focusIndex] { case "role": m.Selection, cmd = m.Selection.Update(msg) - switch m.Selection.Value() { - case "admin", "editor": - m.navMap = &navigationMap - case "writer": - m.navMap = &navigationMapStream - case "reader": - m.navMap = &navigationMapStreamTag - case "ingestor": - m.navMap = &navigationMapStream - default: - m.navMap = &navigationMapNone - } - case "stream": - m.Stream, cmd = m.Stream.Update(msg) - case "tag": - m.Tag, cmd = m.Tag.Update(msg) case "button": m.button, cmd = m.button.Update(msg) } - m.button.Invalid = !m.Valid() } } return m, cmd @@ -207,19 +147,11 @@ func (m Model) View() string { buffer = m.Selection.View() } fmt.Fprintln(&b, buffer) - case "stream": - fmt.Fprintln(&b, m.Stream.View()) - case "tag": - fmt.Fprintln(&b, m.Tag.View()) case "button": fmt.Fprintln(&b) fmt.Fprintln(&b, m.button.View()) } } - if m.Selection.Value() == "none" { - fmt.Fprintln(&b, blurredStyle.Render("Press enter to create user without a role")) - } - return b.String() } From 804ce1df8362547dcadcc1806ae5c571d2c269c6 Mon Sep 17 00:00:00 2001 From: Pratik Jadhav Date: Wed, 29 Jul 2026 13:03:14 +0530 Subject: [PATCH 2/2] fix: updated readme and msg for user cmd --- README.md | 7 +++++++ cmd/role.go | 4 +++- cmd/user.go | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c370969..5e5ab1c 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,13 @@ pb status pb version ``` +User and role behavior: + +- `pb user add` creates self-hosted users and requires at least one existing role. Parseable Cloud users must be invited from the Cloud dashboard instead. +- `pb user set-role` adds the requested roles without removing the user's existing role assignments. +- `pb role add` starts an interactive prompt that asks only for a privilege: `admin`, `editor`, `ingestor`, `reader`, or `writer`. +- `pb role remove` (or `pb role rm`) deletes an existing role. If the role name does not exist, the CLI shows the available role names and does not send a delete request. + Short aliases are available for common commands: ```bash diff --git a/cmd/role.go b/cmd/role.go index 29962b5..82da8d0 100644 --- a/cmd/role.go +++ b/cmd/role.go @@ -69,6 +69,7 @@ var AddRoleCmd = &cobra.Command{ Use: "add role-name", Example: " pb role add ingestors", Short: "Add a new role", + Long: "Add a new role using an interactive prompt that asks only for its privilege.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { startTime := time.Now() @@ -147,8 +148,9 @@ var AddRoleCmd = &cobra.Command{ var RemoveRoleCmd = &cobra.Command{ Use: "remove role-name", Aliases: []string{"rm"}, - Example: " pb role remove ingestor", + Example: " pb role remove ingestor\n pb role rm ingestor", Short: "Delete a role", + Long: "Delete an existing role. If the role name is missing, show the available role names without sending a delete request.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { startTime := time.Now() diff --git a/cmd/user.go b/cmd/user.go index da8582e..d2ab266 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -61,8 +61,9 @@ var ( var addUser = &cobra.Command{ Use: "add user-name", - Example: " pb user add bob", + Example: " pb user add bob --role reader", Short: "Add a new user", + Long: "Add a self-hosted user and assign at least one existing role. For Parseable Cloud, invite the user from the dashboard instead.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { startTime := time.Now() @@ -200,6 +201,7 @@ var RemoveUserCmd = &cobra.Command{ var SetUserRoleCmd = &cobra.Command{ Use: "set-role user-name roles", Short: "Add roles to a user", + Long: "Add one or more roles to a user without removing their existing role assignments.", Example: " pb user set-role bob admin,developer", PreRunE: func(_ *cobra.Command, args []string) error { if len(args) < 2 {