From 0680a72ee2ed967c19846b455acbd524e623b7b6 Mon Sep 17 00:00:00 2001 From: sauls8t Date: Wed, 28 Feb 2018 14:55:36 +0000 Subject: [PATCH] list group members & non-members --- domain/group/endpoint.go | 27 +++++++ domain/group/mysql/store.go | 31 +++++++- domain/storer.go | 2 + domain/user/endpoint.go | 25 ++++++ domain/user/mysql/store.go | 29 +++++++ gui/app/components/customize/user-groups.js | 79 ++++++++++++++++++- gui/app/models/group-member.js | 27 +++++++ gui/app/pods/customize/groups/controller.js | 8 -- gui/app/services/group.js | 32 +++++++- gui/app/services/user.js | 21 ++++- gui/app/styles/bootstrap.scss | 4 + gui/app/styles/view/customize.scss | 43 ++++------ .../components/customize/user-groups.hbs | 77 ++++++++++++++---- model/group/group.go | 11 ++- server/routing/routes.go | 4 + 15 files changed, 360 insertions(+), 60 deletions(-) create mode 100644 gui/app/models/group-member.js diff --git a/domain/group/endpoint.go b/domain/group/endpoint.go index 5c59e98c..38c33ff2 100644 --- a/domain/group/endpoint.go +++ b/domain/group/endpoint.go @@ -216,3 +216,30 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { response.WriteEmpty(w) } + +// GetGroupMembers returns all users associated with given group. +func (h *Handler) GetGroupMembers(w http.ResponseWriter, r *http.Request) { + method := "group.GetGroupMembers" + ctx := domain.GetRequestContext(r) + + // Should be no reason for non-admin to see members + if !ctx.Administrator { + response.WriteForbiddenError(w) + return + } + + groupID := request.Param(r, "groupID") + if len(groupID) == 0 { + response.WriteMissingDataError(w, method, "groupID") + return + } + + m, err := h.Store.Group.GetGroupMembers(ctx, groupID) + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + response.WriteJSON(w, m) +} diff --git a/domain/group/mysql/store.go b/domain/group/mysql/store.go index c4bcb89c..d41cbfe0 100644 --- a/domain/group/mysql/store.go +++ b/domain/group/mysql/store.go @@ -59,12 +59,17 @@ func (s Scope) Get(ctx domain.RequestContext, refID string) (g group.Group, err // GetAll returns all user groups for current orgID. func (s Scope) GetAll(ctx domain.RequestContext) (groups []group.Group, err error) { err = s.Runtime.Db.Select(&groups, - `select id, refid, orgid, role as name, purpose, created, revised FROM role WHERE orgid=? ORDER BY role`, + `SELECT a.id, a.refid, a.orgid, a.role as name, a.purpose, a.created, a.revised, COUNT(b.roleid) AS members + FROM role a + LEFT JOIN rolemember b ON a.refid=b.roleid + WHERE a.orgid=? + GROUP BY a.id, a.refid, a.orgid, a.role, a.purpose, a.created, a.revised + ORDER BY a.role`, ctx.OrgID) if err == sql.ErrNoRows || len(groups) == 0 { - groups = []group.Group{} err = nil + groups = []group.Group{} } if err != nil { err = errors.Wrap(err, "select groups") @@ -93,3 +98,25 @@ func (s Scope) Delete(ctx domain.RequestContext, refID string) (rows int64, err b.DeleteConstrained(ctx.Transaction, "role", ctx.OrgID, refID) return b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM rolemember WHERE orgid=\"%s\" AND roleid=\"%s\"", ctx.OrgID, refID)) } + +// GetGroupMembers returns all user associated with given group. +func (s Scope) GetGroupMembers(ctx domain.RequestContext, groupID string) (members []group.Member, err error) { + err = s.Runtime.Db.Select(&members, + `SELECT a.id, a.orgid, a.roleid, a.userid, + IFNULL(b.firstname, '') as firstname, IFNULL(b.lastname, '') as lastname + FROM rolemember a + LEFT JOIN user b ON b.refid=a.userid + WHERE a.orgid=? AND a.roleid=? + ORDER BY b.firstname, b.lastname`, + ctx.OrgID, groupID) + + if err == sql.ErrNoRows || len(members) == 0 { + err = nil + members = []group.Member{} + } + if err != nil { + err = errors.Wrap(err, "select members") + } + + return +} diff --git a/domain/storer.go b/domain/storer.go index af4c523a..fb08f65a 100644 --- a/domain/storer.go +++ b/domain/storer.go @@ -118,6 +118,7 @@ type UserStorer interface { DeactiveUser(ctx RequestContext, userID string) (err error) ForgotUserPassword(ctx RequestContext, email, token string) (err error) CountActiveUsers() (c int) + MatchUsers(ctx RequestContext, text string, maxMatches int) (u []user.User, err error) } // AccountStorer defines required methods for account management @@ -275,4 +276,5 @@ type GroupStorer interface { GetAll(ctx RequestContext) (g []group.Group, err error) Update(ctx RequestContext, g group.Group) (err error) Delete(ctx RequestContext, refID string) (rows int64, err error) + GetGroupMembers(ctx RequestContext, groupID string) (m []group.Member, err error) } diff --git a/domain/user/endpoint.go b/domain/user/endpoint.go index 7e3d0798..2d966eaf 100644 --- a/domain/user/endpoint.go +++ b/domain/user/endpoint.go @@ -644,3 +644,28 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { response.WriteEmpty(w) } + +// MatchUsers returns users where provided text +// matches firstname, lastname, email +func (h *Handler) MatchUsers(w http.ResponseWriter, r *http.Request) { + method := "user.MatchUsers" + ctx := domain.GetRequestContext(r) + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, "text") + h.Runtime.Log.Error(method, err) + return + } + searchText := string(body) + + u, err := h.Store.User.MatchUsers(ctx, searchText, 100) + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + response.WriteJSON(w, u) +} diff --git a/domain/user/mysql/store.go b/domain/user/mysql/store.go index 6d04b44a..45a5d056 100644 --- a/domain/user/mysql/store.go +++ b/domain/user/mysql/store.go @@ -14,6 +14,7 @@ package mysql import ( "database/sql" "fmt" + "strconv" "strings" "time" @@ -255,3 +256,31 @@ func (s Scope) CountActiveUsers() (c int) { return } + +// MatchUsers returns users that have match to either firstname, lastname or email. +func (s Scope) MatchUsers(ctx domain.RequestContext, text string, maxMatches int) (u []user.User, err error) { + text = strings.TrimSpace(strings.ToLower(text)) + likeQuery := "" + if len(text) > 0 { + likeQuery = " AND (LOWER(firstname) LIKE '%" + text + "%' OR LOWER(lastname) LIKE '%" + text + "%' OR LOWER(email) LIKE '%" + text + "%') " + } + + err = s.Runtime.Db.Select(&u, + `SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised, + u.global, a.active, a.editor, a.admin, a.users as viewusers + FROM user u, account a + WHERE a.orgid=? AND u.refid=a.userid AND a.active=1 `+likeQuery+ + `ORDER BY u.firstname,u.lastname LIMIT `+strconv.Itoa(maxMatches), + ctx.OrgID) + + if err == sql.ErrNoRows || len(u) == 0 { + err = nil + u = []user.User{} + } + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("matching users for org %s", ctx.OrgID)) + } + + return +} diff --git a/gui/app/components/customize/user-groups.js b/gui/app/components/customize/user-groups.js index 26be671d..ae61b5e3 100644 --- a/gui/app/components/customize/user-groups.js +++ b/gui/app/components/customize/user-groups.js @@ -11,13 +11,20 @@ import $ from 'jquery'; import { inject as service } from '@ember/service'; +import { debounce } from '@ember/runloop'; import Component from '@ember/component'; import AuthProvider from '../../mixins/auth'; import ModalMixin from '../../mixins/modal'; export default Component.extend(AuthProvider, ModalMixin, { groupSvc: service('group'), + userSvc: service('user'), newGroup: null, + searchText: '', + showUsers: false, + showMembers: true, + users: null, + members: null, didReceiveAttrs() { this._super(...arguments); @@ -35,6 +42,37 @@ export default Component.extend(AuthProvider, ModalMixin, { this.set('newGroup', { name: '', purpose: '' }); }, + loadUsers(searchText) { + this.get('userSvc').matchUsers(searchText).then((users) => { + let members = this.get('members'); + + if (members.length > 0) { + users.forEach((user) => { + let m = members.findBy('userId', user.get('id')); + user.set('isMember', is.not.undefined(m)); + }) + } + + this.set('users', users); + }); + }, + + loadMembers(groupId) { + this.get('groupSvc').getGroupMembers(groupId).then((members) => { + this.set('members', members); + + // if we have no members, then prefetch users (server should limit to top 100 users) + if (members.length === 0) { + this.loadUsers(''); + this.set('showMembers', false); + this.set('showUsers', true); + } else { + this.set('showMembers', true); + this.set('showUsers', false); + } + }); + }, + actions: { onOpenGroupModal() { this.modalOpen("#add-group-modal", {"show": true}, '#new-group-name'); @@ -44,6 +82,7 @@ export default Component.extend(AuthProvider, ModalMixin, { e.preventDefault(); let newGroup = this.get('newGroup'); + if (is.empty(newGroup.name)) { $("#new-group-name").addClass("is-invalid").focus(); return; @@ -82,8 +121,7 @@ export default Component.extend(AuthProvider, ModalMixin, { }, onShowEditModal(groupId) { - let group = this.get('groups').findBy('id', groupId); - this.set('editGroup', group); + this.set('editGroup', this.get('groups').findBy('id', groupId)); this.modalOpen("#edit-group-modal", {"show": true}, '#edit-group-name'); }, @@ -103,6 +141,43 @@ export default Component.extend(AuthProvider, ModalMixin, { this.modalClose("#edit-group-modal"); this.set('editGroup', null); + }, + + onShowMembersModal(groupId) { + this.set('membersGroup', this.get('groups').findBy('id', groupId)); + this.modalOpen("#group-members-modal", {"show": true}, '#group-members-search'); + this.set('members', null); + this.set('users', null); + this.loadMembers(groupId); + }, + + onSearch() { + debounce(this, function() { + let searchText = this.get('searchText'); + let groupId = this.get('membersGroup.id'); + + if (is.not.empty(searchText)) { + this.loadUsers(searchText); + this.set('showMembers', false); + this.set('showUsers', true); + } else { + this.loadMembers(groupId); + this.set('showMembers', true); + this.set('showUsers', false); + } + }, 250); + }, + + onLeaveGroup(userId) { + this.get('groupSvc').leave(this.get('membersGroup.id'), userId).then(() => { + this.load(); + }); + }, + + onJoinGroup(userId) { + this.get('groupSvc').join(this.get('membersGroup.id'), userId).then(() => { + this.load(); + }); } } }); diff --git a/gui/app/models/group-member.js b/gui/app/models/group-member.js new file mode 100644 index 00000000..8b0c0bb2 --- /dev/null +++ b/gui/app/models/group-member.js @@ -0,0 +1,27 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +import { computed } from '@ember/object'; + +export default Model.extend({ + orgId: attr('string'), + roleId: attr('string'), + userId: attr('string'), + + // for UI only + firstname: attr('string'), + lastname: attr('string'), + fullname: computed('firstname', 'lastname', function () { + return `${this.get('firstname')} ${this.get('lastname')}`; + }) +}); diff --git a/gui/app/pods/customize/groups/controller.js b/gui/app/pods/customize/groups/controller.js index 4d871151..f5539165 100644 --- a/gui/app/pods/customize/groups/controller.js +++ b/gui/app/pods/customize/groups/controller.js @@ -9,17 +9,9 @@ // // https://documize.com -import { inject as service } from '@ember/service'; import Controller from '@ember/controller'; export default Controller.extend({ - userService: service('user'), - - init() { - this._super(...arguments); - // this.newUser = { firstname: "", lastname: "", email: "", active: true }; - }, - actions: { } }); diff --git a/gui/app/services/group.js b/gui/app/services/group.js index 3bc79e3a..66604eb7 100644 --- a/gui/app/services/group.js +++ b/gui/app/services/group.js @@ -64,5 +64,35 @@ export default BaseService.extend({ return this.get('ajax').request(`group/${groupId}`, { method: 'DELETE' }); - } + }, + + // Returns users associated with given group + getGroupMembers(groupId) { + return this.get('ajax').request(`group/${groupId}/members`, { + method: 'GET' + }).then((response) => { + let data = []; + + data = response.map((obj) => { + let data = this.get('store').normalize('group-member', obj); + return this.get('store').push(data); + }); + + return data; + }); + }, + + // join adds user to group. + join(groupId, userId) { + return this.get('ajax').request(`group/${groupId}/join/${userId}`, { + method: 'POST' + }); + }, + + // leave removes user from group. + leave(groupId, userId) { + return this.get('ajax').request(`group/${groupId}/leave/${userId}`, { + method: 'DELETE' + }); + }, }); diff --git a/gui/app/services/user.js b/gui/app/services/user.js index 03a983e3..ef300023 100644 --- a/gui/app/services/user.js +++ b/gui/app/services/user.js @@ -145,5 +145,24 @@ export default Service.extend({ method: "POST", data: password }); - } + }, + + // matchUsers on firstname, lastname, email + matchUsers(text) { + return this.get('ajax').request('users/match', { + method: 'POST', + dataType: 'json', + contentType: 'text', + data: text + }).then((response) => { + let data = []; + + data = response.map((obj) => { + let data = this.get('store').normalize('user', obj); + return this.get('store').push(data); + }); + + return data; + }); + } }); diff --git a/gui/app/styles/bootstrap.scss b/gui/app/styles/bootstrap.scss index 44070357..d2b06e58 100644 --- a/gui/app/styles/bootstrap.scss +++ b/gui/app/styles/bootstrap.scss @@ -106,3 +106,7 @@ $link-hover-decoration: none; .modal-80 { max-width: 80% !important; } + +body.modal-open { + padding-right: 0 !important; +} diff --git a/gui/app/styles/view/customize.scss b/gui/app/styles/view/customize.scss index 3601d44f..7cfa1516 100644 --- a/gui/app/styles/view/customize.scss +++ b/gui/app/styles/view/customize.scss @@ -60,39 +60,30 @@ padding: 0; margin: 0; - > .item { + > .group { margin: 15px 0; - padding: 15px; - @include card(); - @include ease-in(); - > .group { - display: inline-block; - - > .name { - font-size: 1.2rem; - color: $color-primary; - } + .name { + font-size: 1.2rem; + color: $color-off-black; > .purpose { font-size: 1rem; - color: $color-off-black; - } - - > .info { - font-size: 0.9rem; - margin-top: 8px; color: $color-gray; + display: inline-block; } } - - > .buttons { - margin-top: 5px; - } - - > .action { - display: inline-block; - } } - } + } + + > .group-users-members { + > .item { + margin: 10px 0; + + > .fullname { + color: $color-primary; + font-size: 1.2rem; + } + } + } } diff --git a/gui/app/templates/components/customize/user-groups.hbs b/gui/app/templates/components/customize/user-groups.hbs index 6038ae29..c1a7da2a 100644 --- a/gui/app/templates/components/customize/user-groups.hbs +++ b/gui/app/templates/components/customize/user-groups.hbs @@ -5,6 +5,7 @@

Groups

Create groups for easier user management — assign users to groups

Add group
+