feat(office): users admin UI, table rendering, and user save fixes
Add Users list/detail with ACL toggles and library people update/status APIs. Fix project table layout and ANSI-safe cell rendering, and decode array responses from people/status so saves no longer fail on JSON unmarshal. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -101,6 +101,8 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
|
||||
return l.Client.GetTaskByID(ctx, item.ID)
|
||||
case model.KindProject:
|
||||
return l.Client.GetProjectByID(ctx, item.ID)
|
||||
case model.KindUser:
|
||||
return l.Client.GetUser(ctx, item.ID)
|
||||
default:
|
||||
if item.Raw != nil {
|
||||
return item.Raw, nil
|
||||
@@ -362,33 +364,6 @@ func mailFolderForSubject(subject model.Subject) (int, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]model.Item, len(users))
|
||||
for i, u := range users {
|
||||
id := ""
|
||||
if u.ID != nil {
|
||||
id = fmt.Sprint(*u.ID)
|
||||
}
|
||||
title := ""
|
||||
if u.DisplayName != nil {
|
||||
title = *u.DisplayName
|
||||
}
|
||||
email := ""
|
||||
if u.Email != nil {
|
||||
email = *u.Email
|
||||
}
|
||||
items[i] = model.Item{
|
||||
ID: id, Title: title, Subtitle: email, Kind: model.KindUser,
|
||||
Raw: map[string]any{"id": id, "displayName": title, "email": email},
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||
resp, err := l.Client.GetProjectFiles(ctx, projectID)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,9 +16,11 @@ func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.For
|
||||
}
|
||||
switch item.Kind {
|
||||
case model.KindTask:
|
||||
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
|
||||
return l.UpdateTask(ctx, item.ID, fields)
|
||||
case model.KindProject:
|
||||
return l.saveProject(ctx, item.ID, fields)
|
||||
case model.KindUser:
|
||||
return l.SaveUser(ctx, item.ID, fields)
|
||||
default:
|
||||
return fmt.Errorf("save not supported for %s", item.Kind)
|
||||
}
|
||||
@@ -60,5 +62,13 @@ func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFie
|
||||
if err != nil {
|
||||
return model.FormFields{}, err
|
||||
}
|
||||
return model.FormFieldsFromRaw(item.Kind, raw), nil
|
||||
fields := model.FormFieldsFromRaw(item.Kind, raw)
|
||||
if item.Kind == model.KindTask {
|
||||
choices, uerr := l.LoadUserChoices(ctx)
|
||||
if uerr != nil {
|
||||
return model.FormFields{}, uerr
|
||||
}
|
||||
fields.UserChoices = choices
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
+32
-15
@@ -9,8 +9,18 @@ import (
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// UpdateTask saves title and description for a project task.
|
||||
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
|
||||
// UpdateTask saves editable task fields via the typed JSON API.
|
||||
func (l *Loader) UpdateTask(ctx context.Context, taskID string, fields model.FormFields) error {
|
||||
return l.updateTask(ctx, taskID, fields, false)
|
||||
}
|
||||
|
||||
// CloseTask saves fields and sets status to closed.
|
||||
func (l *Loader) CloseTask(ctx context.Context, taskID string, fields model.FormFields) error {
|
||||
fields.TaskStatus = model.TaskLifecycleClosed
|
||||
return l.updateTask(ctx, taskID, fields, true)
|
||||
}
|
||||
|
||||
func (l *Loader) updateTask(ctx context.Context, taskID string, fields model.FormFields, closing bool) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
@@ -18,19 +28,26 @@ func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description stri
|
||||
if err != nil {
|
||||
return fmt.Errorf("task id %q: %w", taskID, err)
|
||||
}
|
||||
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
|
||||
status := onlyoffice.ProjectTaskStatus(fields.TaskStatus)
|
||||
if closing {
|
||||
status = onlyoffice.ProjectTaskStatusClosed
|
||||
}
|
||||
req := onlyoffice.ProjectTaskUpdateRequest{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Description: description,
|
||||
})
|
||||
Title: fields.Primary,
|
||||
Description: fields.Secondary,
|
||||
Status: status,
|
||||
}
|
||||
if fields.ResponsibleID != "" {
|
||||
req.Responsible = []string{fields.ResponsibleID}
|
||||
} else if !closing {
|
||||
raw, derr := l.Detail(ctx, model.Item{ID: taskID, Kind: model.KindTask})
|
||||
if derr == nil {
|
||||
if rid := model.TaskResponsibleIDFromRaw(raw); rid != "" {
|
||||
req.Responsible = []string{rid}
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = l.Client.UpdateProjectTask(req)
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskFields loads title and description for a project task item.
|
||||
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
|
||||
fields, err := l.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return fields.Primary, fields.Secondary, nil
|
||||
}
|
||||
|
||||
@@ -20,27 +20,27 @@ func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
|
||||
t.Skip("no tasks")
|
||||
}
|
||||
item := items[0]
|
||||
title, desc, err := loader.TaskFields(ctx, item)
|
||||
fields, err := loader.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreTitle, restoreDesc := title, desc
|
||||
restore := fields
|
||||
t.Cleanup(func() {
|
||||
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
|
||||
_ = loader.UpdateTask(context.Background(), item.ID, restore)
|
||||
})
|
||||
newTitle := title + " (office TUI test)"
|
||||
newDesc := desc + "\n\n_edited by office integration test_"
|
||||
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
|
||||
fields.Primary = fields.Primary + " (office TUI test)"
|
||||
fields.Secondary = fields.Secondary + "\n\n_edited by office integration test_"
|
||||
if err := loader.UpdateTask(ctx, item.ID, fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
|
||||
got, err := loader.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTitle != newTitle {
|
||||
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
|
||||
if got.Primary != fields.Primary {
|
||||
t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
|
||||
}
|
||||
if gotDesc != newDesc {
|
||||
if got.Secondary != fields.Secondary {
|
||||
t.Fatalf("description mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]model.Item, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
raw := userToRaw(u)
|
||||
id := strMap(raw, "id")
|
||||
userName := strMap(raw, "userName")
|
||||
if userName == "" {
|
||||
userName = strMap(raw, "email")
|
||||
}
|
||||
items = append(items, model.Item{
|
||||
ID: id,
|
||||
Title: userName,
|
||||
Kind: model.KindUser,
|
||||
Raw: raw,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func userToRaw(u *onlyoffice.User) map[string]any {
|
||||
b, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func strMap(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
if m[key] == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
|
||||
func userProfileUpdateBody(isAdmin bool, modules []string) map[string]any {
|
||||
body := map[string]any{
|
||||
"isAdmin": isAdmin,
|
||||
}
|
||||
if !isAdmin {
|
||||
body["listAdminModules"] = modules
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// SaveUser persists user account settings from the detail form.
|
||||
func (l *Loader) SaveUser(ctx context.Context, userID string, fields model.FormFields) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
raw, err := l.Client.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasEnabled := model.UserIsEnabled(raw)
|
||||
|
||||
if fields.UserEnabled && !wasEnabled {
|
||||
if err := l.Client.ChangeUserStatus(ctx, userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin, modules := fields.UserACL.APIPayload()
|
||||
body := userProfileUpdateBody(isAdmin, modules)
|
||||
if _, err := l.updateUserProfile(ctx, userID, body, fields.UserEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !fields.UserEnabled && wasEnabled {
|
||||
if err := l.Client.ChangeUserStatus(ctx, userID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if fields.UserPassword != "" {
|
||||
if err := l.Client.ChangeUserPassword(ctx, userID, fields.UserPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Loader) updateUserProfile(ctx context.Context, userID string, body map[string]any, wantEnabled bool) (map[string]any, error) {
|
||||
out, err := l.Client.UpdateUser(ctx, userID, body)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if wantEnabled && isSuspendedUserError(err) {
|
||||
if actErr := l.Client.ChangeUserStatus(ctx, userID, true); actErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.Client.UpdateUser(ctx, userID, body)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func isSuspendedUserError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "suspended") || strings.Contains(msg, "terminated")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserProfileUpdateBodyOmitsStatus(t *testing.T) {
|
||||
body := userProfileUpdateBody(true, nil)
|
||||
if _, ok := body["status"]; ok {
|
||||
t.Fatal("profile update must not include status")
|
||||
}
|
||||
if body["isAdmin"] != true {
|
||||
t.Fatal("expected isAdmin true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserProfileUpdateBodyPartialAdminModules(t *testing.T) {
|
||||
body := userProfileUpdateBody(false, []string{"documents", "crm"})
|
||||
if body["isAdmin"] != false {
|
||||
t.Fatal("expected partial admin")
|
||||
}
|
||||
mods, ok := body["listAdminModules"].([]string)
|
||||
if !ok || len(mods) != 2 {
|
||||
t.Fatalf("modules=%v", body["listAdminModules"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSuspendedUserError(t *testing.T) {
|
||||
err := fmt.Errorf(`PUT JSON /api/2.0/people/x: 500 {"error":{"message":"The user is suspended"}}`)
|
||||
if !isSuspendedUserError(err) {
|
||||
t.Fatal("expected suspended detection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// LoadUserChoices returns portal users for responsible pickers.
|
||||
func (l *Loader) LoadUserChoices(ctx context.Context) ([]model.UserOption, error) {
|
||||
if l == nil || l.Client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.UserOption, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil || u.ID == nil || *u.ID == "" {
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
if u.DisplayName != nil {
|
||||
name = *u.DisplayName
|
||||
}
|
||||
if name == "" && u.Email != nil {
|
||||
name = *u.Email
|
||||
}
|
||||
out = append(out, model.UserOption{ID: *u.ID, Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user