diff --git a/pkg/cmd/user.go b/pkg/cmd/user.go index 3837e3aa1..e7c9a295f 100644 --- a/pkg/cmd/user.go +++ b/pkg/cmd/user.go @@ -120,7 +120,7 @@ var userListCmd = &cobra.Command{ s := db.NewSession() defer s.Close() - users, err := user.ListUsers(s, "") + users, err := user.ListAllUsers(s) if err != nil { _ = s.Rollback() log.Fatalf("Error getting users: %s", err) diff --git a/pkg/db/fixtures/users.yml b/pkg/db/fixtures/users.yml index 9c152be64..5f5153e59 100644 --- a/pkg/db/fixtures/users.yml +++ b/pkg/db/fixtures/users.yml @@ -58,6 +58,7 @@ email: 'user7@example.com' is_active: true issuer: local + discoverable_by_email: true updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 8 @@ -86,6 +87,7 @@ created: 2018-12-01 15:13:12 - id: 11 username: 'user11' + name: 'Some one else' password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user11@example.com' is_active: true @@ -94,10 +96,12 @@ created: 2018-12-01 15:13:12 - id: 12 username: 'user12' + name: 'Name with spaces' password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user12@example.com' is_active: true issuer: local + discoverable_by_name: true updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 13 diff --git a/pkg/integrations/user_list_test.go b/pkg/integrations/user_list_test.go index bdcf140e3..6850fa199 100644 --- a/pkg/integrations/user_list_test.go +++ b/pkg/integrations/user_list_test.go @@ -28,11 +28,7 @@ func TestUserList(t *testing.T) { t.Run("Normal test", func(t *testing.T) { rec, err := newTestRequestWithUser(t, http.MethodPost, apiv1.UserList, &testuser1, "", nil, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `user1`) - assert.Contains(t, rec.Body.String(), `user2`) - assert.Contains(t, rec.Body.String(), `user3`) - assert.Contains(t, rec.Body.String(), `user4`) - assert.Contains(t, rec.Body.String(), `user5`) + assert.Equal(t, "null\n", rec.Body.String()) }) t.Run("Search for user3", func(t *testing.T) { rec, err := newTestRequestWithUser(t, http.MethodPost, apiv1.UserList, &testuser1, "", map[string][]string{"s": {"user3"}}, nil) diff --git a/pkg/migration/20210407170753.go b/pkg/migration/20210407170753.go new file mode 100644 index 000000000..5865f5c78 --- /dev/null +++ b/pkg/migration/20210407170753.go @@ -0,0 +1,44 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type users20210407170753 struct { + DiscoverableByName bool `xorm:"bool default false index" json:"-"` + DiscoverableByEmail bool `xorm:"bool default false index" json:"-"` +} + +func (users20210407170753) TableName() string { + return "users" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20210407170753", + Description: "Add discoverable by email or name columns", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(users20210407170753{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/users_list_test.go b/pkg/models/users_list_test.go index 7c11b4bc7..5ea9889cd 100644 --- a/pkg/models/users_list_test.go +++ b/pkg/models/users_list_test.go @@ -93,6 +93,7 @@ func TestListUsersFromList(t *testing.T) { IsActive: true, Issuer: "local", EmailRemindersEnabled: true, + DiscoverableByEmail: true, Created: testCreatedTime, Updated: testUpdatedTime, } @@ -129,6 +130,7 @@ func TestListUsersFromList(t *testing.T) { testuser11 := &user.User{ ID: 11, Username: "user11", + Name: "Some one else", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, Issuer: "local", @@ -139,10 +141,12 @@ func TestListUsersFromList(t *testing.T) { testuser12 := &user.User{ ID: 12, Username: "user12", + Name: "Name with spaces", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, Issuer: "local", EmailRemindersEnabled: true, + DiscoverableByName: true, Created: testCreatedTime, Updated: testUpdatedTime, } diff --git a/pkg/routes/api/v1/user_list.go b/pkg/routes/api/v1/user_list.go index 1f4d4581e..28bae9ef1 100644 --- a/pkg/routes/api/v1/user_list.go +++ b/pkg/routes/api/v1/user_list.go @@ -31,11 +31,11 @@ import ( // UserList gets all information about a user // @Summary Get users -// @Description Lists all users (without emailadresses). Also possible to search for a specific user. +// @Description Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings. // @tags user // @Accept json // @Produce json -// @Param s query string false "Search for a user by its name." +// @Param s query string false "The search criteria." // @Security JWTKeyAuth // @Success 200 {array} user.User "All (found) users." // @Failure 400 {object} web.HTTPError "Something's invalid." diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 08fc34e47..8c5571961 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -38,7 +38,11 @@ type UserSettings struct { // The new name of the current user. Name string `json:"name"` // If enabled, sends email reminders of tasks to the user. - EmailRemindersEnabled bool `xorm:"bool default false" json:"email_reminders_enabled"` + EmailRemindersEnabled bool `json:"email_reminders_enabled"` + // If true, this user can be found by their name or parts of it when searching for it. + DiscoverableByName bool `json:"discoverable_by_name"` + // If true, the user can be found when searching for their exact email. + DiscoverableByEmail bool `json:"discoverable_by_email"` } // GetUserAvatarProvider returns the currently set user avatar @@ -161,6 +165,8 @@ func UpdateGeneralUserSettings(c echo.Context) error { user.Name = us.Name user.EmailRemindersEnabled = us.EmailRemindersEnabled + user.DiscoverableByEmail = us.DiscoverableByEmail + user.DiscoverableByName = us.DiscoverableByName _, err = user2.UpdateUser(s, user) if err != nil { diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go index 2265089b1..266281a42 100644 --- a/pkg/routes/api/v1/user_show.go +++ b/pkg/routes/api/v1/user_show.go @@ -19,6 +19,8 @@ package v1 import ( "net/http" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" @@ -28,6 +30,11 @@ import ( "github.com/labstack/echo/v4" ) +type userWithSettings struct { + user.User + Settings *UserSettings `json:"settings"` +} + // UserShow gets all informations about the current user // @Summary Get user information // @Description Returns the current user object. @@ -48,10 +55,20 @@ func UserShow(c echo.Context) error { s := db.NewSession() defer s.Close() - user, err := models.GetUserOrLinkShareUser(s, a) + u, err := models.GetUserOrLinkShareUser(s, a) if err != nil { return handler.HandleHTTPError(err, c) } - return c.JSON(http.StatusOK, user) + us := &userWithSettings{ + User: *u, + Settings: &UserSettings{ + Name: u.Name, + EmailRemindersEnabled: u.EmailRemindersEnabled, + DiscoverableByName: u.DiscoverableByName, + DiscoverableByEmail: u.DiscoverableByEmail, + }, + } + + return c.JSON(http.StatusOK, us) } diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 91f3669a5..6f4005a67 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -6976,7 +6976,7 @@ var doc = `{ "JWTKeyAuth": [] } ], - "description": "Lists all users (without emailadresses). Also possible to search for a specific user.", + "description": "Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings.", "consumes": [ "application/json" ], @@ -6990,7 +6990,7 @@ var doc = `{ "parameters": [ { "type": "string", - "description": "Search for a user by its name.", + "description": "The search criteria.", "name": "s", "in": "query" } @@ -8534,6 +8534,14 @@ var doc = `{ "v1.UserSettings": { "type": "object", "properties": { + "discoverable_by_email": { + "description": "If true, the user can be found when searching for their exact email.", + "type": "boolean" + }, + "discoverable_by_name": { + "description": "If true, this user can be found by their name or parts of it when searching for it.", + "type": "boolean" + }, "email_reminders_enabled": { "description": "If enabled, sends email reminders of tasks to the user.", "type": "boolean" diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index d6ab5fe85..f5c42c025 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -6959,7 +6959,7 @@ "JWTKeyAuth": [] } ], - "description": "Lists all users (without emailadresses). Also possible to search for a specific user.", + "description": "Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings.", "consumes": [ "application/json" ], @@ -6973,7 +6973,7 @@ "parameters": [ { "type": "string", - "description": "Search for a user by its name.", + "description": "The search criteria.", "name": "s", "in": "query" } @@ -8517,6 +8517,14 @@ "v1.UserSettings": { "type": "object", "properties": { + "discoverable_by_email": { + "description": "If true, the user can be found when searching for their exact email.", + "type": "boolean" + }, + "discoverable_by_name": { + "description": "If true, this user can be found by their name or parts of it when searching for it.", + "type": "boolean" + }, "email_reminders_enabled": { "description": "If enabled, sends email reminders of tasks to the user.", "type": "boolean" diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index cdd914760..26b9da991 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1079,6 +1079,12 @@ definitions: type: object v1.UserSettings: properties: + discoverable_by_email: + description: If true, the user can be found when searching for their exact email. + type: boolean + discoverable_by_name: + description: If true, this user can be found by their name or parts of it when searching for it. + type: boolean email_reminders_enabled: description: If enabled, sends email reminders of tasks to the user. type: boolean @@ -5662,9 +5668,9 @@ paths: get: consumes: - application/json - description: Lists all users (without emailadresses). Also possible to search for a specific user. + description: Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings. parameters: - - description: Search for a user by its name. + - description: The search criteria. in: query name: s type: string diff --git a/pkg/user/user.go b/pkg/user/user.go index bbf397f2e..de7fd8547 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -70,6 +70,9 @@ type User struct { // If enabled, sends email reminders of tasks to the user. EmailRemindersEnabled bool `xorm:"bool default true" json:"-"` + DiscoverableByName bool `xorm:"bool default false index" json:"-"` + DiscoverableByEmail bool `xorm:"bool default false index" json:"-"` + // A timestamp when this task was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this task was last updated. You cannot change this value. @@ -366,6 +369,8 @@ func UpdateUser(s *xorm.Session, user *User) (updatedUser *User, err error) { "is_active", "name", "email_reminders_enabled", + "discoverable_by_name", + "discoverable_by_email", ). Update(user) if err != nil { diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index aef3033ef..495870a52 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -373,10 +373,63 @@ func TestListUsers(t *testing.T) { s := db.NewSession() defer s.Close() - all, err := ListUsers(s, "") + all, err := ListAllUsers(s) assert.NoError(t, err) assert.Len(t, all, 14) }) + t.Run("no search term", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + all, err := ListUsers(s, "") + assert.NoError(t, err) + assert.Len(t, all, 0) + }) + t.Run("not discoverable by email", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + all, err := ListUsers(s, "user1@example.com") + assert.NoError(t, err) + assert.Len(t, all, 0) + db.AssertExists(t, "users", map[string]interface{}{ + "email": "user1@example.com", + }, false) + }) + t.Run("not discoverable by name", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + all, err := ListUsers(s, "one else") + assert.NoError(t, err) + assert.Len(t, all, 0) + db.AssertExists(t, "users", map[string]interface{}{ + "name": "Some one else", + }, false) + }) + t.Run("discoverable by email", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + all, err := ListUsers(s, "user7@example.com") + assert.NoError(t, err) + assert.Len(t, all, 1) + assert.Equal(t, int64(7), all[0].ID) + }) + t.Run("discoverable by partial name", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + all, err := ListUsers(s, "with space") + assert.NoError(t, err) + assert.Len(t, all, 1) + assert.Equal(t, int64(12), all[0].ID) + }) } func TestUserPasswordReset(t *testing.T) { diff --git a/pkg/user/users_list.go b/pkg/user/users_list.go index 50db5f53d..2d72e6a37 100644 --- a/pkg/user/users_list.go +++ b/pkg/user/users_list.go @@ -17,42 +17,40 @@ package user import ( - "strconv" "strings" + "xorm.io/builder" "xorm.io/xorm" - - "code.vikunja.io/api/pkg/log" ) // ListUsers returns a list with all users, filtered by an optional searchstring -func ListUsers(s *xorm.Session, searchterm string) (users []*User, err error) { +func ListUsers(s *xorm.Session, search string) (users []*User, err error) { - vals := strings.Split(searchterm, ",") - ids := []int64{} - for _, val := range vals { - v, err := strconv.ParseInt(val, 10, 64) - if err != nil { - log.Debugf("User search string part '%s' is not a number: %s", val, err) - continue - } - ids = append(ids, v) - } + // Prevent searching for placeholders + search = strings.ReplaceAll(search, "%", "") - if len(ids) > 0 { - err = s. - In("id", ids). - Find(&users) - return - } - - if searchterm == "" { - err = s.Find(&users) + if search == "" || strings.ReplaceAll(search, " ", "") == "" { return } err = s. - Where("username LIKE ?", "%"+searchterm+"%"). + Where(builder.Or( + builder.Like{"username", "%" + search + "%"}, + builder.And( + builder.Eq{"email": search}, + builder.Eq{"discoverable_by_email": true}, + ), + builder.And( + builder.Like{"name", "%" + search + "%"}, + builder.Eq{"discoverable_by_name": true}, + ), + )). Find(&users) return } + +// ListAllUsers returns all users +func ListAllUsers(s *xorm.Session) (users []*User, err error) { + err = s.Find(&users) + return +}