From 1dc99d699712694cdd6f5b0d6f5c3cb05acade76 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 14:26:23 +0100 Subject: [PATCH] feat(oo): project team CRUD and user lifecycle (block/unblock/password/delete) - project team: oo projects team list|add|remove|set (portal users) - users: get|create|update|delete|block|unblock|password - lib: CreateUser, DeleteUser (auto-suspend before delete), Add/Remove/ SetProjectTeam, ListProjectTeam; deleteJSON reused; jsonBodyReader helper - tests: unmarshalResponseArray --- cmd/oo/main.go | 4 +- cmd/oo/projects.go | 131 +++++++++++++++++++++++ cmd/oo/users.go | 259 +++++++++++++++++++++++++++++++++++++++++++++ http.go | 99 ++++++++++++----- http_test.go | 23 ++++ project_team.go | 39 +++++++ users.go | 47 ++++++++ 7 files changed, 572 insertions(+), 30 deletions(-) create mode 100644 project_team.go diff --git a/cmd/oo/main.go b/cmd/oo/main.go index c91baaa..ddedf78 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -3,9 +3,9 @@ // Command tree is subject-based (mirrors the library split and the `tea` CLI): // // oo calendar list | events | add | delete -// oo projects list | get | milestones | milestone-create | create | update | delete | contacts (add|remove) | link-authors | link-git | files (list|upload|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx) +// oo projects list | get | milestones | milestone-create | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx) // oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach) -// oo users list | self (alias: oo whoami) +// oo users list | self | get | create | update | delete | block | unblock | password (alias: oo whoami) // oo contacts list | get | delete | info-add | merge | dedupe-info | tags | tag-add | tag-create | tag-remove // oo persons list | create | delete | dedupe // oo companies list | create | delete | dedupe | dedupe-persons diff --git a/cmd/oo/projects.go b/cmd/oo/projects.go index cdc4a7f..52004e2 100644 --- a/cmd/oo/projects.go +++ b/cmd/oo/projects.go @@ -28,6 +28,137 @@ func init() { projectsCmd.AddCommand(prjUpdateCmd()) projectsCmd.AddCommand(prjDeleteCmd()) projectsCmd.AddCommand(prjContactsCmd()) + projectsCmd.AddCommand(prjTeamCmd()) +} + +func prjTeamCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "team", + Short: "Project team (portal users) — CRUD", + Long: `Project team members are portal users (People), not CRM contacts. +CRM companies/persons linked to a project live under 'oo projects contacts'.`, + } + cmd.AddCommand(prjTeamListCmd()) + cmd.AddCommand(prjTeamAddCmd()) + cmd.AddCommand(prjTeamRemoveCmd()) + cmd.AddCommand(prjTeamSetCmd()) + return cmd +} + +func prjTeamListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list PROJECT_ID", + Short: "List portal users on the project team", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pid, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("project id: %w", err) + } + c, err := newOO(cmd) + if err != nil { + return err + } + list, err := c.ListProjectTeam(cmd.Context(), pid) + if err != nil { + return err + } + printTable([]string{"id", "displayName", "userName", "email", "isAdmin"}, teamRows(list)) + return nil + }, + } +} + +func prjTeamAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add PROJECT_ID USER_ID [USER_ID...]", + Short: "Add portal user(s) to the project team", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + pid, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("project id: %w", err) + } + c, err := newOO(cmd) + if err != nil { + return err + } + for _, uid := range args[1:] { + if _, err := c.AddProjectTeamUser(cmd.Context(), pid, uid); err != nil { + return fmt.Errorf("add %s: %w", uid, err) + } + printObject(map[string]any{"project_id": pid, "user_id": uid, "added": true}) + } + return nil + }, + } +} + +func prjTeamRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove PROJECT_ID USER_ID [USER_ID...]", + Short: "Remove portal user(s) from the project team", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + pid, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("project id: %w", err) + } + c, err := newOO(cmd) + if err != nil { + return err + } + for _, uid := range args[1:] { + if _, err := c.RemoveProjectTeamUser(cmd.Context(), pid, uid); err != nil { + return fmt.Errorf("remove %s: %w", uid, err) + } + printObject(map[string]any{"project_id": pid, "user_id": uid, "removed": true}) + } + return nil + }, + } +} + +func prjTeamSetCmd() *cobra.Command { + var notify bool + cmd := &cobra.Command{ + Use: "set PROJECT_ID USER_ID [USER_ID...]", + Short: "Replace the project team with the given users (register several at once)", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + pid, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("project id: %w", err) + } + c, err := newOO(cmd) + if err != nil { + return err + } + team, err := c.SetProjectTeam(cmd.Context(), pid, args[1:], notify) + if err != nil { + return err + } + printTable([]string{"id", "displayName", "userName", "email", "isAdmin"}, teamRows(team)) + return nil + }, + } + cmd.Flags().BoolVar(¬ify, "notify", false, "notify added members") + return cmd +} + +// teamRows maps raw team member maps into table rows. +func teamRows(list []map[string]any) []map[string]any { + rows := make([]map[string]any, 0, len(list)) + for _, m := range list { + rows = append(rows, map[string]any{ + "id": idString(m, "id"), + "displayName": m["displayName"], + "userName": m["userName"], + "email": m["email"], + "isAdmin": m["isAdmin"], + }) + } + return rows } func prjListCmd() *cobra.Command { diff --git a/cmd/oo/users.go b/cmd/oo/users.go index 474825d..d307eea 100644 --- a/cmd/oo/users.go +++ b/cmd/oo/users.go @@ -1,6 +1,12 @@ package main import ( + "bufio" + "fmt" + "os" + "strings" + + onlyoffice "github.com/eslider/go-onlyoffice" "github.com/spf13/cobra" ) @@ -13,6 +19,13 @@ func init() { rootCmd.AddCommand(usersCmd) usersCmd.AddCommand(usersListCmd()) usersCmd.AddCommand(usersSelfCmd()) + usersCmd.AddCommand(usersGetCmd()) + usersCmd.AddCommand(usersCreateCmd()) + usersCmd.AddCommand(usersUpdateCmd()) + usersCmd.AddCommand(usersDeleteCmd()) + usersCmd.AddCommand(usersBlockCmd()) + usersCmd.AddCommand(usersUnblockCmd()) + usersCmd.AddCommand(usersPasswordCmd()) rootCmd.AddCommand(whoamiCmd()) } @@ -65,6 +78,252 @@ func usersSelfCmd() *cobra.Command { } } +func usersGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get USER_ID", + Short: "Show one portal user profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + u, err := c.GetUser(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(u) + return nil + }, + } +} + +func usersCreateCmd() *cobra.Command { + var first, last, email, password, title, location, sex, comment string + var visitor bool + cmd := &cobra.Command{ + Use: "create", + Short: "Create a portal user", + Long: `Create a portal user (POST /api/2.0/people). + +Without --password the portal generates one and the account stays NotActivated +until the user follows the activation link. With --password the account is +Active immediately. Use --visitor for a guest account.`, + RunE: func(cmd *cobra.Command, args []string) error { + if email == "" || first == "" || last == "" { + return fmt.Errorf("--email, --first and --last are required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + req := onlyoffice.NewUserRequest{ + FirstName: first, + LastName: last, + Email: email, + Password: password, + Title: title, + Location: location, + Sex: sex, + Comment: comment, + } + if cmd.Flags().Changed("visitor") { + req.IsVisitor = &visitor + } + u, err := c.CreateUser(cmd.Context(), req) + if err != nil { + return err + } + printObject(map[string]any{ + "id": idString(u, "id"), + "displayName": idString(u, "displayName"), + "email": idString(u, "email"), + "status": u["status"], + }) + return nil + }, + } + cmd.Flags().StringVar(&first, "first", "", "first name (required)") + cmd.Flags().StringVar(&last, "last", "", "last name (required)") + cmd.Flags().StringVar(&email, "email", "", "email (required)") + cmd.Flags().StringVar(&password, "password", "", "initial password (default: portal-generated)") + cmd.Flags().StringVar(&title, "title", "", "job title") + cmd.Flags().StringVar(&location, "location", "", "location") + cmd.Flags().StringVar(&sex, "sex", "", "sex: male|female") + cmd.Flags().StringVar(&comment, "comment", "", "comment") + cmd.Flags().BoolVar(&visitor, "visitor", false, "create as guest (isVisitor=true)") + return cmd +} + +func usersUpdateCmd() *cobra.Command { + var first, last, email, title, location, sex, comment string + cmd := &cobra.Command{ + Use: "update USER_ID", + Short: "Update portal user profile fields (only flags passed)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body := map[string]any{} + if cmd.Flags().Changed("first") { + body["firstname"] = first + } + if cmd.Flags().Changed("last") { + body["lastname"] = last + } + if cmd.Flags().Changed("email") { + body["email"] = email + } + if cmd.Flags().Changed("title") { + body["title"] = title + } + if cmd.Flags().Changed("location") { + body["location"] = location + } + if cmd.Flags().Changed("sex") { + body["sex"] = sex + } + if cmd.Flags().Changed("comment") { + body["comment"] = comment + } + if len(body) == 0 { + return fmt.Errorf("nothing to update: pass at least one of --first/--last/--email/--title/--location/--sex/--comment") + } + c, err := newOO(cmd) + if err != nil { + return err + } + u, err := c.UpdateUser(cmd.Context(), args[0], body) + if err != nil { + return err + } + printObject(map[string]any{ + "id": idString(u, "id"), + "displayName": idString(u, "displayName"), + }) + return nil + }, + } + cmd.Flags().StringVar(&first, "first", "", "first name") + cmd.Flags().StringVar(&last, "last", "", "last name") + cmd.Flags().StringVar(&email, "email", "", "email") + cmd.Flags().StringVar(&title, "title", "", "job title") + cmd.Flags().StringVar(&location, "location", "", "location") + cmd.Flags().StringVar(&sex, "sex", "", "sex: male|female") + cmd.Flags().StringVar(&comment, "comment", "", "comment") + return cmd +} + +func usersDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete USER_ID [USER_ID...]", + Aliases: []string{"rm"}, + Short: "Delete portal user(s) permanently", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, id := range args { + u, err := c.DeleteUser(cmd.Context(), id) + if err != nil { + return fmt.Errorf("delete %s: %w", id, err) + } + printObject(map[string]any{"id": id, "deleted": true, "displayName": idString(u, "displayName")}) + } + return nil + }, + } +} + +func usersBlockCmd() *cobra.Command { + return &cobra.Command{ + Use: "block USER_ID [USER_ID...]", + Aliases: []string{"disable"}, + Short: "Block (terminate) user(s): login denied, profile kept", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, id := range args { + if err := c.BlockUser(cmd.Context(), id); err != nil { + return fmt.Errorf("block %s: %w", id, err) + } + printObject(map[string]any{"id": id, "blocked": true}) + } + return nil + }, + } +} + +func usersUnblockCmd() *cobra.Command { + return &cobra.Command{ + Use: "unblock USER_ID [USER_ID...]", + Aliases: []string{"enable", "activate"}, + Short: "Unblock (activate) user(s)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, id := range args { + if err := c.UnblockUser(cmd.Context(), id); err != nil { + return fmt.Errorf("unblock %s: %w", id, err) + } + printObject(map[string]any{"id": id, "unblocked": true}) + } + return nil + }, + } +} + +func usersPasswordCmd() *cobra.Command { + var password string + cmd := &cobra.Command{ + Use: "password USER_ID", + Short: "Set a user password (reads stdin when --password is empty)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pwd := password + if pwd == "" { + b, err := readLine(os.Stdin) + if err != nil { + return fmt.Errorf("read password: %w", err) + } + pwd = b + } + if pwd == "" { + return fmt.Errorf("password is empty") + } + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.ChangeUserPassword(cmd.Context(), args[0], pwd); err != nil { + return err + } + printObject(map[string]any{"id": args[0], "password_changed": true}) + return nil + }, + } + cmd.Flags().StringVar(&password, "password", "", "new password (omit to read one line from stdin)") + return cmd +} + +// readLine reads a single trimmed line from r. +func readLine(r *os.File) (string, error) { + sc := bufio.NewScanner(r) + if !sc.Scan() { + if err := sc.Err(); err != nil { + return "", err + } + return "", nil + } + return strings.TrimSpace(sc.Text()), nil +} + // whoamiCmd is a convenience shortcut at the root level. func whoamiCmd() *cobra.Command { return &cobra.Command{ diff --git a/http.go b/http.go index 5cf8787..bbdac25 100644 --- a/http.go +++ b/http.go @@ -111,6 +111,25 @@ func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any, return unmarshalResponseObject(raw) } +// unmarshalResponseArray extracts the "response" field from a raw OnlyOffice +// envelope and decodes it into a list of maps. Returns (nil, nil) for a null, +// empty or scalar payload. Companion to unmarshalResponseObject for endpoints +// whose response is a list (project team, people/status, …). +func unmarshalResponseArray(raw json.RawMessage) ([]map[string]any, error) { + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + if len(resp) == 0 || string(resp) == "null" || resp[0] != '[' { + return nil, nil + } + var list []map[string]any + if err := json.Unmarshal(resp, &list); err != nil { + return nil, err + } + return list, nil +} + // unmarshalResponseObject extracts the "response" field from a raw OnlyOffice // envelope and decodes it into map[string]any. Returns (nil, nil) for a null // response, an empty array, or scalar payloads. When the API returns a list @@ -258,26 +277,61 @@ func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map return unmarshalResponseObject(raw) } +// jsonBodyReader turns a request body value into an io.Reader. nil becomes +// "{}", []byte/string pass through, anything else is JSON-marshalled. +func jsonBodyReader(body any) (io.Reader, error) { + switch b := body.(type) { + case nil: + return strings.NewReader("{}"), nil + case []byte: + return bytes.NewReader(b), nil + case string: + return strings.NewReader(b), nil + default: + buf, err := json.Marshal(b) + if err != nil { + return nil, err + } + return bytes.NewReader(buf), nil + } +} + +// postJSONArray is postJSON + unmarshalResponseArray. +func (c *Client) postJSONArray(ctx context.Context, path string, body any) ([]map[string]any, error) { + raw, err := c.postJSON(ctx, path, body) + if err != nil { + return nil, err + } + return unmarshalResponseArray(raw) +} + +// putJSONArray is putJSON + unmarshalResponseArray. +func (c *Client) putJSONArray(ctx context.Context, path string, body any) ([]map[string]any, error) { + raw, err := c.putJSON(ctx, path, body) + if err != nil { + return nil, err + } + return unmarshalResponseArray(raw) +} + +// deleteJSONArray is deleteJSON + unmarshalResponseArray. +func (c *Client) deleteJSONArray(ctx context.Context, path string, body any) ([]map[string]any, error) { + raw, err := c.deleteJSON(ctx, path, body) + if err != nil { + return nil, err + } + return unmarshalResponseArray(raw) +} + // postJSON issues an authenticated POST with application/json body. func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { auth, err := c.authHeader() if err != nil { return nil, err } - var rdr io.Reader - switch b := body.(type) { - case nil: - rdr = strings.NewReader("{}") - case []byte: - rdr = bytes.NewReader(b) - case string: - rdr = strings.NewReader(b) - default: - buf, err := json.Marshal(b) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(buf) + rdr, err := jsonBodyReader(body) + if err != nil { + return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr) if err != nil { @@ -307,20 +361,9 @@ func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMe if err != nil { return nil, err } - var rdr io.Reader - switch b := body.(type) { - case nil: - rdr = strings.NewReader("{}") - case []byte: - rdr = bytes.NewReader(b) - case string: - rdr = strings.NewReader(b) - default: - buf, err := json.Marshal(b) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(buf) + rdr, err := jsonBodyReader(body) + if err != nil { + return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL()+path, rdr) if err != nil { diff --git a/http_test.go b/http_test.go index ed51c11..7e2c162 100644 --- a/http_test.go +++ b/http_test.go @@ -48,3 +48,26 @@ func TestUnmarshalResponseObjectNull(t *testing.T) { t.Fatalf("expected nil, got %#v", out) } } + +func TestUnmarshalResponseArrayList(t *testing.T) { + raw := json.RawMessage(`{"response":[{"id":"a","displayName":"A"},{"id":"b","displayName":"B"}]}`) + out, err := unmarshalResponseArray(raw) + if err != nil { + t.Fatal(err) + } + if len(out) != 2 || out[0]["id"] != "a" || out[1]["displayName"] != "B" { + t.Fatalf("unexpected list: %#v", out) + } +} + +func TestUnmarshalResponseArrayNullAndScalar(t *testing.T) { + for _, raw := range []string{`{"response":null}`, `{"response":{}}`, `{"response":"x"}`} { + out, err := unmarshalResponseArray(json.RawMessage(raw)) + if err != nil { + t.Fatalf("%s: %v", raw, err) + } + if out != nil { + t.Fatalf("%s: expected nil, got %#v", raw, out) + } + } +} diff --git a/project_team.go b/project_team.go new file mode 100644 index 0000000..4dd0798 --- /dev/null +++ b/project_team.go @@ -0,0 +1,39 @@ +package onlyoffice + +// Project team (portal users) CRUD. Team is distinct from CRM contacts, +// which are linked through project_contacts.go. + +import ( + "context" + "fmt" +) + +// ListProjectTeam returns portal users on the project team. +// GET /api/2.0/project/{projectid}/team +func (c *Client) ListProjectTeam(ctx context.Context, projectID int) ([]map[string]any, error) { + return c.GetProjectTeam(ctx, projectID) +} + +// AddProjectTeamUser adds a portal user to the project team. +// POST /api/2.0/project/{projectid}/team with {"userId": "..."}. +// Returns the resulting team list. +func (c *Client) AddProjectTeamUser(ctx context.Context, projectID int, userID string) ([]map[string]any, error) { + return c.postJSONArray(ctx, fmt.Sprintf("/api/2.0/project/%d/team", projectID), + map[string]any{"userId": userID}) +} + +// RemoveProjectTeamUser removes a portal user from the project team. +// DELETE /api/2.0/project/{projectid}/team with {"userId": "..."}. +// Returns the resulting team list. +func (c *Client) RemoveProjectTeamUser(ctx context.Context, projectID int, userID string) ([]map[string]any, error) { + return c.deleteJSONArray(ctx, fmt.Sprintf("/api/2.0/project/%d/team", projectID), + map[string]any{"userId": userID}) +} + +// SetProjectTeam replaces the project team with the given user IDs. +// PUT /api/2.0/project/{projectid}/team with participants + notify. +// This is the canonical way to register several members at once. +func (c *Client) SetProjectTeam(ctx context.Context, projectID int, participants []string, notify bool) ([]map[string]any, error) { + return c.putJSONArray(ctx, fmt.Sprintf("/api/2.0/project/%d/team", projectID), + map[string]any{"projectId": projectID, "participants": participants, "notify": notify}) +} diff --git a/users.go b/users.go index 8d759e7..9d84a1c 100644 --- a/users.go +++ b/users.go @@ -106,6 +106,53 @@ func (c *Client) ChangeUserPassword(ctx context.Context, userID, password string return err } +// NewUserRequest is the payload for CreateUser. Field names follow the +// OnlyOffice REST contract (lowercase firstname/lastname). +type NewUserRequest struct { + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + Email string `json:"email"` + Password string `json:"password,omitempty"` + Title string `json:"title,omitempty"` + Location string `json:"location,omitempty"` + Sex string `json:"sex,omitempty"` + Comment string `json:"comment,omitempty"` + IsVisitor *bool `json:"isVisitor,omitempty"` + Department []string `json:"department,omitempty"` +} + +// CreateUser adds a portal user (POST /api/2.0/people). Returns the created +// user profile. When Password is empty the portal generates one and the account +// stays NotActivated until the user follows the activation link. +func (c *Client) CreateUser(ctx context.Context, req NewUserRequest) (map[string]any, error) { + return c.postJSONObject(ctx, "/api/2.0/people", req) +} + +// DeleteUser removes a portal user permanently (DELETE /api/2.0/people/{id}). +// OnlyOffice refuses to delete an active user ("The user is not suspended"), +// so a blocked/terminated account is deactivated first and deletion retried. +func (c *Client) DeleteUser(ctx context.Context, userID string) (map[string]any, error) { + u, err := c.deleteObject(ctx, fmt.Sprintf("/api/2.0/people/%s", url.PathEscape(userID))) + if err == nil { + return u, nil + } + if bErr := c.BlockUser(ctx, userID); bErr != nil { + return nil, err + } + return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/people/%s", url.PathEscape(userID))) +} + +// BlockUser terminates (blocks) the user: login is denied but the profile and +// data are kept. Reversible with UnblockUser. +func (c *Client) BlockUser(ctx context.Context, userID string) error { + return c.ChangeUserStatus(ctx, userID, false) +} + +// UnblockUser reactivates a terminated/blocked user. +func (c *Client) UnblockUser(ctx context.Context, userID string) error { + return c.ChangeUserStatus(ctx, userID, true) +} + // SelfUserID returns the ID of the authenticated user (people/@self), cached. func (c *Client) SelfUserID(ctx context.Context) (string, error) { if c.selfID != "" { -- 2.54.0